From a8088483efa44a6ca9075b20629d1929c74ea9e9 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 20 Jun 2026 21:47:29 +0200 Subject: [PATCH 001/111] feat(docker): add local-build runtime scaffold --- docker/Dockerfile | 60 ++++++++++++++++++++++++++++++++++ docker/compose.yml | 81 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 docker/Dockerfile create mode 100644 docker/compose.yml diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..bc68941 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,60 @@ +# syntax=docker/dockerfile:1.7 + +ARG NODE_VERSION=22-bookworm-slim + +FROM node:${NODE_VERSION} AS package + +ARG PI_WEB_VERSION=latest +ARG PI_VERSION=latest +ARG CACHE_BUST=local + +ENV NPM_CONFIG_UPDATE_NOTIFIER=false + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates g++ make python3 \ + && rm -rf /var/lib/apt/lists/* + +RUN set -eux; \ + echo "PI WEB Docker build cache bust: ${CACHE_BUST}"; \ + npm install -g --omit=dev --no-audit --no-fund \ + "@jmfederico/pi-web@${PI_WEB_VERSION}" \ + "@earendil-works/pi-coding-agent@${PI_VERSION}"; \ + npm cache clean --force + +FROM node:${NODE_VERSION} AS runtime + +ENV NODE_ENV=production \ + NPM_CONFIG_UPDATE_NOTIFIER=false \ + HOME=/data/home \ + XDG_CONFIG_HOME=/data/config \ + PI_WEB_HOST=0.0.0.0 \ + PI_WEB_PORT=8504 \ + PI_WEB_DATA_DIR=/data/pi-web \ + PI_WEB_SESSIOND_SOCKET=/data/pi-web/sessiond.sock \ + PI_CODING_AGENT_DIR=/data/pi-agent \ + SHELL=/bin/bash \ + TERM=xterm-256color + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + openssh-client \ + procps \ + tini \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /data/home /data/config /data/pi-web /data/pi-agent /workspace \ + && chown -R node:node /data /workspace + +COPY --from=package /usr/local/lib/node_modules /usr/local/lib/node_modules +COPY --from=package /usr/local/bin /usr/local/bin + +WORKDIR /workspace +USER node + +EXPOSE 8504 + +ENTRYPOINT ["tini", "--"] +CMD ["pi-web-server"] diff --git a/docker/compose.yml b/docker/compose.yml new file mode 100644 index 0000000..7fd5975 --- /dev/null +++ b/docker/compose.yml @@ -0,0 +1,81 @@ +name: pi-web + +x-pi-web-build: &pi-web-build + context: . + dockerfile: Dockerfile + args: + PI_WEB_VERSION: ${PI_WEB_VERSION:-latest} + PI_VERSION: ${PI_VERSION:-latest} + CACHE_BUST: ${CACHE_BUST:-local} + +x-pi-web-environment: &pi-web-environment + HOME: /data/home + XDG_CONFIG_HOME: /data/config + PI_WEB_DATA_DIR: /data/pi-web + PI_WEB_SESSIOND_SOCKET: /data/pi-web/sessiond.sock + PI_CODING_AGENT_DIR: /data/pi-agent + PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864} + +x-pi-web-volumes: &pi-web-volumes + - type: bind + source: ${PI_WEB_DOCKER_DATA_DIR:-./data} + target: /data + - type: bind + source: /var/run/docker.sock + target: /var/run/docker.sock + - type: bind + source: /srv + target: /srv + - type: bind + source: /opt + target: /opt + - type: bind + source: /home + target: /home + - type: bind + source: / + target: /host + read_only: true + +services: + sessiond: + build: *pi-web-build + image: ${PI_WEB_IMAGE:-pi-web:local} + command: ["pi-web-sessiond"] + restart: unless-stopped + user: "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}" + group_add: + - "${DOCKER_GID:-0}" + environment: *pi-web-environment + volumes: *pi-web-volumes + healthcheck: + test: ["CMD-SHELL", "test -S /data/pi-web/sessiond.sock"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 10s + + web: + build: *pi-web-build + image: ${PI_WEB_IMAGE:-pi-web:local} + command: ["pi-web-server"] + restart: unless-stopped + depends_on: + sessiond: + condition: service_healthy + user: "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}" + group_add: + - "${DOCKER_GID:-0}" + environment: + <<: *pi-web-environment + PI_WEB_HOST: 0.0.0.0 + PI_WEB_PORT: "8504" + ports: + - "${PI_WEB_BIND_ADDR:-127.0.0.1}:${PI_WEB_PORT:-8504}:8504" + volumes: *pi-web-volumes + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8504/api/pi-web/runtime >/dev/null"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s From 099d770eebdf861962f180a5b3c02e46301b1b76 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 20 Jun 2026 21:58:53 +0200 Subject: [PATCH 002/111] feat(docker): add host command bridge --- .changeset/docker-host-command-bridge.md | 5 +++ docker/Dockerfile | 6 +++ docker/bin/hostexec | 48 ++++++++++++++++++++++++ docker/compose.yml | 5 +++ 4 files changed, 64 insertions(+) create mode 100644 .changeset/docker-host-command-bridge.md create mode 100755 docker/bin/hostexec diff --git a/.changeset/docker-host-command-bridge.md b/.changeset/docker-host-command-bridge.md new file mode 100644 index 0000000..b740e7d --- /dev/null +++ b/.changeset/docker-host-command-bridge.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a Docker runtime host command bridge for explicitly running host administration commands from containerized PI WEB sessions. diff --git a/docker/Dockerfile b/docker/Dockerfile index bc68941..787aad0 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,9 @@ # syntax=docker/dockerfile:1.7 ARG NODE_VERSION=22-bookworm-slim +ARG DOCKER_CLI_VERSION=29-cli + +FROM docker:${DOCKER_CLI_VERSION} AS docker-cli FROM node:${NODE_VERSION} AS package @@ -32,6 +35,7 @@ ENV NODE_ENV=production \ PI_WEB_DATA_DIR=/data/pi-web \ PI_WEB_SESSIOND_SOCKET=/data/pi-web/sessiond.sock \ PI_CODING_AGENT_DIR=/data/pi-agent \ + HOSTEXEC_IMAGE=alpine:3.22 \ SHELL=/bin/bash \ TERM=xterm-256color @@ -50,6 +54,8 @@ RUN apt-get update \ COPY --from=package /usr/local/lib/node_modules /usr/local/lib/node_modules COPY --from=package /usr/local/bin /usr/local/bin +COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker +COPY --chmod=0755 bin/hostexec /usr/local/bin/hostexec WORKDIR /workspace USER node diff --git a/docker/bin/hostexec b/docker/bin/hostexec new file mode 100755 index 0000000..f70cc61 --- /dev/null +++ b/docker/bin/hostexec @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: hostexec + +Run a command on the Docker host by starting a temporary privileged helper +container through the mounted Docker socket and entering the host namespaces. +EOF +} + +if [ "$#" -eq 0 ]; then + usage + exit 64 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "hostexec: docker CLI not found in this container" >&2 + exit 127 +fi + +docker_host="${DOCKER_HOST:-unix:///var/run/docker.sock}" +if [[ "$docker_host" == unix://* ]]; then + socket_path="${docker_host#unix://}" + if [ ! -S "$socket_path" ]; then + echo "hostexec: Docker socket is not accessible as a Unix socket at $socket_path" >&2 + exit 69 + fi +fi + +helper_image="${HOSTEXEC_IMAGE:-alpine:3.22}" +tty_args=(--interactive) +if [ -t 0 ] && [ -t 1 ]; then + tty_args+=(--tty) +fi + +exec docker run \ + --rm \ + "${tty_args[@]}" \ + --pull=missing \ + --privileged \ + --security-opt label=disable \ + --pid=host \ + --network=host \ + --volume /:/host:rw \ + "$helper_image" \ + nsenter -t 1 -m -u -i -n -p -- "$@" diff --git a/docker/compose.yml b/docker/compose.yml index 7fd5975..b98ac1c 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -14,6 +14,7 @@ x-pi-web-environment: &pi-web-environment PI_WEB_DATA_DIR: /data/pi-web PI_WEB_SESSIOND_SOCKET: /data/pi-web/sessiond.sock PI_CODING_AGENT_DIR: /data/pi-agent + HOSTEXEC_IMAGE: ${HOSTEXEC_IMAGE:-alpine:3.22} PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864} x-pi-web-volumes: &pi-web-volumes @@ -46,6 +47,8 @@ services: user: "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}" group_add: - "${DOCKER_GID:-0}" + security_opt: + - label=disable environment: *pi-web-environment volumes: *pi-web-volumes healthcheck: @@ -66,6 +69,8 @@ services: user: "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}" group_add: - "${DOCKER_GID:-0}" + security_opt: + - label=disable environment: <<: *pi-web-environment PI_WEB_HOST: 0.0.0.0 From 1fa17260836a7d1e90808bf3c7009ed69d529993 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 20 Jun 2026 22:08:19 +0200 Subject: [PATCH 003/111] feat(docker): add one-liner installer --- .changeset/docker-installer-update.md | 5 + docker/.dockerignore | 5 + docker/install.sh | 407 ++++++++++++++++++++++++++ 3 files changed, 417 insertions(+) create mode 100644 .changeset/docker-installer-update.md create mode 100644 docker/.dockerignore create mode 100755 docker/install.sh diff --git a/.changeset/docker-installer-update.md b/.changeset/docker-installer-update.md new file mode 100644 index 0000000..2bb5d9d --- /dev/null +++ b/.changeset/docker-installer-update.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a one-liner Docker install/update flow that refreshes local runtime assets, preserves persistent data, rebuilds without cache, and recreates the split PI WEB services. diff --git a/docker/.dockerignore b/docker/.dockerignore new file mode 100644 index 0000000..1bb8da0 --- /dev/null +++ b/docker/.dockerignore @@ -0,0 +1,5 @@ +# Keep the local-build runtime context small and avoid sending persistent data. +* +!Dockerfile +!bin/ +!bin/hostexec diff --git a/docker/install.sh b/docker/install.sh new file mode 100755 index 0000000..6800042 --- /dev/null +++ b/docker/install.sh @@ -0,0 +1,407 @@ +#!/usr/bin/env sh +set -eu + +log() { + printf '%s\n' "$*" >&2 +} + +die() { + log "pi-web Docker installer: $*" + exit 1 +} + +usage() { + cat <<'EOF' +Usage: docker/install.sh [options] + +Install or update the local-build PI WEB Docker runtime. The installer refreshes +Docker assets in the install directory, writes host-specific .env values, +rebuilds the image without using cache, and recreates the split sessiond/web +services without deleting persistent data. + +Options: + --install-dir DIR Install directory (default: $XDG_DATA_HOME/pi-web-docker + or ~/.local/share/pi-web-docker) + --data-dir DIR Persistent data directory (default: INSTALL_DIR/data) + --bind-address ADDR Host bind address (default: 127.0.0.1) + --port PORT Host port (default: 8504) + --pi-web-version VER npm @jmfederico/pi-web version pin (default: latest) + --pi-version VER npm @earendil-works/pi-coding-agent version pin + (default: latest) + --asset-dir DIR Copy Docker assets from a local docker/ directory + --asset-ref REF Fetch Docker assets from a Git ref (default: main) + --skip-compose Write assets/.env but skip build and service recreate + -h, --help Show this help + +Environment variables with the same names used in .env may also be set before +running the installer, for example: + + PI_WEB_VERSION=1.202606.4 PI_VERSION=0.79.1 docker/install.sh +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --install-dir) + [ "$#" -ge 2 ] || die "--install-dir requires a value" + PI_WEB_DOCKER_HOME=$2 + shift 2 + ;; + --data-dir) + [ "$#" -ge 2 ] || die "--data-dir requires a value" + PI_WEB_DOCKER_DATA_DIR=$2 + shift 2 + ;; + --bind-address) + [ "$#" -ge 2 ] || die "--bind-address requires a value" + PI_WEB_BIND_ADDR=$2 + shift 2 + ;; + --port) + [ "$#" -ge 2 ] || die "--port requires a value" + PI_WEB_PORT=$2 + shift 2 + ;; + --pi-web-version) + [ "$#" -ge 2 ] || die "--pi-web-version requires a value" + PI_WEB_VERSION=$2 + shift 2 + ;; + --pi-version) + [ "$#" -ge 2 ] || die "--pi-version requires a value" + PI_VERSION=$2 + shift 2 + ;; + --asset-dir) + [ "$#" -ge 2 ] || die "--asset-dir requires a value" + PI_WEB_DOCKER_ASSET_DIR=$2 + shift 2 + ;; + --asset-ref) + [ "$#" -ge 2 ] || die "--asset-ref requires a value" + PI_WEB_DOCKER_REF=$2 + shift 2 + ;; + --skip-compose) + PI_WEB_DOCKER_SKIP_COMPOSE=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown argument: $1" + ;; + esac +done + +absolute_dir() { + dir=$1 + mkdir -p "$dir" || return 1 + (cd "$dir" && pwd -P) +} + +absolute_existing_dir() { + dir=$1 + (cd "$dir" && pwd -P) +} + +path_from_base() { + base=$1 + path=$2 + case "$path" in + /*) printf '%s\n' "$path" ;; + *) printf '%s/%s\n' "$base" "$path" ;; + esac +} + +strip_wrapping_quotes() { + value=$1 + case "$value" in + \"*\") + case "$value" in + *\") value=${value#\"}; value=${value%\"} ;; + esac + ;; + \'*\') + case "$value" in + *\') value=${value#\'}; value=${value%\'} ;; + esac + ;; + esac + printf '%s\n' "$value" +} + +existing_env_value() { + key=$1 + [ -f "$env_file" ] || return 1 + raw=$(awk -v key="$key" ' + function trim(value) { + sub(/^[ \t]+/, "", value) + sub(/[ \t\r]+$/, "", value) + return value + } + /^[ \t]*(#|$)/ { next } + { + line = $0 + sub(/^[ \t]*export[ \t]+/, "", line) + name = line + sub(/=.*/, "", name) + name = trim(name) + if (name == key) { + sub(/^[^=]*=/, "", line) + print trim(line) + found = 1 + exit + } + } + END { if (!found) exit 1 } + ' "$env_file") || return 1 + strip_wrapping_quotes "$raw" +} + +value_from_env_or_default() { + key=$1 + default_value=$2 + eval "is_set=\${$key+x}" + if [ "${is_set:-}" = x ]; then + eval "printf '%s\n' \"\${$key}\"" + else + printf '%s\n' "$default_value" + fi +} + +value_from_env_or_existing_or_default() { + key=$1 + default_value=$2 + eval "is_set=\${$key+x}" + if [ "${is_set:-}" = x ]; then + eval "printf '%s\n' \"\${$key}\"" + elif existing=$(existing_env_value "$key"); then + printf '%s\n' "$existing" + else + printf '%s\n' "$default_value" + fi +} + +require_non_empty() { + name=$1 + value=$2 + [ -n "$value" ] || die "$name must not be empty" +} + +detect_docker_gid() { + if [ -S /var/run/docker.sock ]; then + if gid=$(stat -c '%g' /var/run/docker.sock 2>/dev/null); then + printf '%s\n' "$gid" + return 0 + fi + if gid=$(stat -f '%g' /var/run/docker.sock 2>/dev/null); then + printf '%s\n' "$gid" + return 0 + fi + fi + + if command -v getent >/dev/null 2>&1; then + if gid=$(getent group docker | awk -F: 'NR == 1 { print $3 }'); then + if [ -n "$gid" ]; then + printf '%s\n' "$gid" + return 0 + fi + fi + fi + + printf '0\n' +} + +fetch_url() { + url=$1 + target=$2 + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$target" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$target" "$url" + else + die "curl or wget is required to fetch Docker assets" + fi +} + +find_local_asset_dir() { + if [ -f "${0:-}" ]; then + candidate_dir=$(dirname "$0") + if candidate_dir=$(absolute_existing_dir "$candidate_dir" 2>/dev/null); then + if [ -f "$candidate_dir/Dockerfile" ] && [ -f "$candidate_dir/compose.yml" ]; then + printf '%s\n' "$candidate_dir" + return 0 + fi + fi + fi + + return 1 +} + +write_asset() { + rel_path=$1 + mode=$2 + target=$install_dir/$rel_path + temp_target=$target.$$ + mkdir -p "$(dirname "$target")" + + if [ -n "$asset_dir" ]; then + [ -f "$asset_dir/$rel_path" ] || die "missing Docker asset: $asset_dir/$rel_path" + cp "$asset_dir/$rel_path" "$temp_target" + else + fetch_url "$asset_base/$rel_path" "$temp_target" + fi + + chmod "$mode" "$temp_target" + mv "$temp_target" "$target" +} + +compose_cmd() { + if docker compose version >/dev/null 2>&1; then + docker compose "$@" + elif command -v docker-compose >/dev/null 2>&1; then + docker-compose "$@" + else + die "Docker Compose is required (docker compose plugin or docker-compose)" + fi +} + +if [ -n "${XDG_DATA_HOME:-}" ]; then + default_data_home=$XDG_DATA_HOME +elif [ -n "${HOME:-}" ]; then + default_data_home=$HOME/.local/share +else + default_data_home= +fi + +default_install_dir= +if [ -n "$default_data_home" ]; then + default_install_dir=$default_data_home/pi-web-docker +fi +install_dir_input=${PI_WEB_DOCKER_HOME:-$default_install_dir} +[ -n "$install_dir_input" ] || die "HOME, XDG_DATA_HOME, or PI_WEB_DOCKER_HOME must be set" +install_dir=$(absolute_dir "$install_dir_input") || die "could not create install directory" +env_file=$install_dir/.env + +if [ "${PI_WEB_DOCKER_ASSET_DIR+x}" = x ]; then + asset_dir=$(absolute_existing_dir "$PI_WEB_DOCKER_ASSET_DIR") || die "asset directory does not exist: $PI_WEB_DOCKER_ASSET_DIR" + asset_base= + log "Using Docker assets from $asset_dir" +elif asset_dir=$(find_local_asset_dir 2>/dev/null); then + asset_base= + log "Using Docker assets from $asset_dir" +else + asset_ref=${PI_WEB_DOCKER_REF:-main} + asset_base=${PI_WEB_DOCKER_ASSET_BASE:-https://raw.githubusercontent.com/jmfederico/pi-web/$asset_ref/docker} + asset_dir= + log "Fetching Docker assets from $asset_base" +fi + +write_asset Dockerfile 0644 +write_asset compose.yml 0644 +write_asset .dockerignore 0644 +write_asset install.sh 0755 +write_asset bin/hostexec 0755 + +pi_web_uid=$(value_from_env_or_default PI_WEB_UID "$(id -u)") +pi_web_gid=$(value_from_env_or_default PI_WEB_GID "$(id -g)") +docker_gid=$(value_from_env_or_default DOCKER_GID "$(detect_docker_gid)") + +raw_data_dir=$(value_from_env_or_existing_or_default PI_WEB_DOCKER_DATA_DIR "$install_dir/data") +data_dir=$(absolute_dir "$(path_from_base "$install_dir" "$raw_data_dir")") || die "could not create data directory" + +pi_web_bind_addr=$(value_from_env_or_existing_or_default PI_WEB_BIND_ADDR 127.0.0.1) +pi_web_port=$(value_from_env_or_existing_or_default PI_WEB_PORT 8504) +pi_web_version=$(value_from_env_or_existing_or_default PI_WEB_VERSION latest) +pi_version=$(value_from_env_or_existing_or_default PI_VERSION latest) +pi_web_image=$(value_from_env_or_existing_or_default PI_WEB_IMAGE pi-web:local) +hostexec_image=$(value_from_env_or_existing_or_default HOSTEXEC_IMAGE alpine:3.22) +pi_web_max_upload_bytes=$(value_from_env_or_existing_or_default PI_WEB_MAX_UPLOAD_BYTES 67108864) + +require_non_empty PI_WEB_UID "$pi_web_uid" +require_non_empty PI_WEB_GID "$pi_web_gid" +require_non_empty DOCKER_GID "$docker_gid" +require_non_empty PI_WEB_DOCKER_DATA_DIR "$data_dir" +require_non_empty PI_WEB_BIND_ADDR "$pi_web_bind_addr" +require_non_empty PI_WEB_PORT "$pi_web_port" +require_non_empty PI_WEB_VERSION "$pi_web_version" +require_non_empty PI_VERSION "$pi_version" +require_non_empty PI_WEB_IMAGE "$pi_web_image" +require_non_empty HOSTEXEC_IMAGE "$hostexec_image" +require_non_empty PI_WEB_MAX_UPLOAD_BYTES "$pi_web_max_upload_bytes" + +umask 077 +temp_env=$env_file.$$ +cat >"$temp_env" </dev/null 2>&1; then + die "docker CLI is required" +fi + +if ! docker info >/dev/null 2>&1; then + die "docker daemon is not reachable by this user" +fi + +cache_bust=${CACHE_BUST:-install-$(date -u +%Y%m%dT%H%M%SZ)} + +log "" +log "WARNING: updating recreates the PI WEB Docker session daemon." +log "Active Pi agent runtimes inside this Docker install can stop; update while sessions are idle." +log "Persistent data under $data_dir is kept. The installer does not run 'docker compose down -v'." +log "" +log "Building $pi_web_image with --pull --no-cache (CACHE_BUST=$cache_bust) ..." +( + cd "$install_dir" + CACHE_BUST=$cache_bust compose_cmd -f compose.yml build --pull --no-cache +) + +log "Recreating split PI WEB Docker services ..." +( + cd "$install_dir" + compose_cmd -f compose.yml up -d --force-recreate --remove-orphans +) + +log "" +log "PI WEB Docker runtime is ready: http://$pi_web_bind_addr:$pi_web_port" +log "Install directory: $install_dir" +log "To update later, re-run this installer." +( + cd "$install_dir" + compose_cmd -f compose.yml ps +) From fb36f48fea8630a7b1119b146bde52294bb1628a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 20 Jun 2026 22:18:15 +0200 Subject: [PATCH 004/111] feat(docker): add development compose setup --- .changeset/docker-development-setup.md | 5 ++ .dockerignore | 9 +++ docker/Dockerfile.dev | 52 ++++++++++++++ docker/compose.dev.yml | 95 ++++++++++++++++++++++++++ 4 files changed, 161 insertions(+) create mode 100644 .changeset/docker-development-setup.md create mode 100644 .dockerignore create mode 100644 docker/Dockerfile.dev create mode 100644 docker/compose.dev.yml diff --git a/.changeset/docker-development-setup.md b/.changeset/docker-development-setup.md new file mode 100644 index 0000000..cab3d24 --- /dev/null +++ b/.changeset/docker-development-setup.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a Docker development setup that builds from the local checkout while keeping the session daemon separate from autoreloading web/API/client services. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..34f60e9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +node_modules +dist +.pi-web +.playwright-cli +dev-plugins +*.log +.env +.DS_Store diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev new file mode 100644 index 0000000..cdd5afc --- /dev/null +++ b/docker/Dockerfile.dev @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1.7 + +ARG NODE_VERSION=22-bookworm-slim +ARG DOCKER_CLI_VERSION=29-cli + +FROM docker:${DOCKER_CLI_VERSION} AS docker-cli + +FROM node:${NODE_VERSION} AS dev + +ENV NODE_ENV=development \ + NPM_CONFIG_UPDATE_NOTIFIER=false \ + NPM_CONFIG_CACHE=/data/npm-cache \ + HOME=/data/home \ + XDG_CONFIG_HOME=/data/config \ + PI_WEB_DATA_DIR=/data/pi-web \ + PI_WEB_SESSIOND_SOCKET=/data/pi-web/sessiond.sock \ + PI_CODING_AGENT_DIR=/data/pi-agent \ + HOSTEXEC_IMAGE=alpine:3.22 \ + SHELL=/bin/bash \ + TERM=xterm-256color + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + g++ \ + git \ + make \ + openssh-client \ + procps \ + python3 \ + tini \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +COPY package.json package-lock.json ./ +COPY scripts/install-git-hooks.mjs scripts/install-git-hooks.mjs +RUN npm ci \ + && npm cache clean --force \ + && mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent \ + && chmod -R a+rwX /workspace/node_modules /data \ + && chmod 0777 /workspace + +COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker +COPY --chmod=0755 docker/bin/hostexec /usr/local/bin/hostexec + +EXPOSE 8504 8505 + +ENTRYPOINT ["tini", "--"] +CMD ["npm", "run", "dev"] diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml new file mode 100644 index 0000000..c85fe2a --- /dev/null +++ b/docker/compose.dev.yml @@ -0,0 +1,95 @@ +name: pi-web-dev + +x-pi-web-dev-build: &pi-web-dev-build + context: .. + dockerfile: docker/Dockerfile.dev + +x-pi-web-dev-environment: &pi-web-dev-environment + HOME: /data/home + XDG_CONFIG_HOME: /data/config + PI_WEB_DATA_DIR: /data/pi-web + PI_WEB_SESSIOND_SOCKET: /data/pi-web/sessiond.sock + PI_CODING_AGENT_DIR: /data/pi-agent + HOSTEXEC_IMAGE: ${HOSTEXEC_IMAGE:-alpine:3.22} + PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864} + NPM_CONFIG_UPDATE_NOTIFIER: "false" + NPM_CONFIG_CACHE: /data/npm-cache + +x-pi-web-dev-volumes: &pi-web-dev-volumes + - type: bind + source: .. + target: /workspace + - type: volume + source: node_modules + target: /workspace/node_modules + - type: volume + source: data + target: /data + - type: bind + source: /var/run/docker.sock + target: /var/run/docker.sock + - type: bind + source: /srv + target: /srv + - type: bind + source: /opt + target: /opt + - type: bind + source: /home + target: /home + - type: bind + source: / + target: /host + read_only: true + +services: + sessiond: + build: *pi-web-dev-build + image: ${PI_WEB_DEV_IMAGE:-pi-web:dev} + command: ["npm", "run", "start:sessiond"] + working_dir: /workspace + user: "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}" + group_add: + - "${DOCKER_GID:-0}" + security_opt: + - label=disable + environment: *pi-web-dev-environment + volumes: *pi-web-dev-volumes + healthcheck: + test: ["CMD-SHELL", "test -S /data/pi-web/sessiond.sock"] + interval: 5s + timeout: 3s + retries: 24 + start_period: 5s + + web: + build: *pi-web-dev-build + image: ${PI_WEB_DEV_IMAGE:-pi-web:dev} + command: ["bash", "-lc", "trap 'kill 0' EXIT; npm run dev:web & npm run dev:client & wait"] + working_dir: /workspace + depends_on: + sessiond: + condition: service_healthy + user: "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}" + group_add: + - "${DOCKER_GID:-0}" + security_opt: + - label=disable + environment: + <<: *pi-web-dev-environment + PI_WEB_HOST: 0.0.0.0 + PI_WEB_PORT: "8504" + ports: + - "${PI_WEB_DEV_API_BIND_ADDR:-127.0.0.1}:${PI_WEB_DEV_API_PORT:-8504}:8504" + - "${PI_WEB_DEV_BIND_ADDR:-127.0.0.1}:${PI_WEB_DEV_PORT:-8505}:8505" + volumes: *pi-web-dev-volumes + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8504/api/pi-web/runtime >/dev/null"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s + +volumes: + data: + node_modules: From b9b03d985857abf816bfd39844f4f59ffd7bee6a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 20 Jun 2026 22:24:56 +0200 Subject: [PATCH 005/111] docs(docker): document runtime and dev usage --- .changeset/docker-usage-docs.md | 5 + README.md | 23 ++++ docker/README.md | 233 ++++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 .changeset/docker-usage-docs.md create mode 100644 docker/README.md diff --git a/.changeset/docker-usage-docs.md b/.changeset/docker-usage-docs.md new file mode 100644 index 0000000..5de2bcc --- /dev/null +++ b/.changeset/docker-usage-docs.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Document Docker runtime and development usage, including trust warnings, update/version pinning, localhost exposure, and host command examples. diff --git a/README.md b/README.md index 2ccad3c..3ba2682 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,18 @@ One-line install is also available for users who prefer it: curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/install.sh | sh ``` +### Docker local-build runtime + +A Docker runtime is available for trusted local/server installs without using prebuilt images: + +```bash +curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh +``` + +It builds a local image from npm, runs split `sessiond` and `web` services, binds the browser UI to `127.0.0.1:8504` by default, and uses the same command as the update path. The Docker setup intentionally mounts the Docker socket and selected host paths; treat it as root-equivalent host access, do not expose it directly to the public internet, and use an SSH tunnel, VPN, or authenticated reverse proxy for remote access. + +See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, host command examples, and development Compose usage. + PI WEB is also published as a Pi package. Installing it through Pi exposes a `/pi-web` command inside Pi: ```bash @@ -234,6 +246,17 @@ pi-web install --dev `dev:web` also watches bundled plugin TypeScript and rebuilds the browser-loaded plugin JavaScript under `dist/pi-web-plugins/`. You can restart `dev:web` or `dev:client` without stopping active Pi sessions. +Docker development from the checkout is available too: + +```bash +export PI_WEB_UID=$(id -u) +export PI_WEB_GID=$(id -g) +export DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) +docker compose -f docker/compose.dev.yml up --build +``` + +Open . The Docker dev setup keeps `sessiond` separate from the autoreloading web/API/client service. See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md#development-docker-setup). + ## Production-style run from a checkout ```bash diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..f3440aa --- /dev/null +++ b/docker/README.md @@ -0,0 +1,233 @@ +# PI WEB Docker + +PI WEB has two Docker modes: + +- **Runtime/server mode** builds a local image from npm packages and runs split `sessiond` + `web` services. This is for users and servers. +- **Development mode** builds from this checkout and runs the same split shape while letting the web/API/client services autoreload. This is for hacking on PI WEB. + +No prebuilt image or registry is required in either mode. + +## Trust model: read this first + +The Docker setup is for trusted single-user or trusted-admin environments. It is not a sandbox and it is not suitable for untrusted multi-tenant use. + +By design, the runtime containers get deliberate host access so PI WEB agents can work on real server paths: + +- `/var/run/docker.sock` is mounted into the containers. The Docker socket is root-equivalent on the host. +- `/srv`, `/opt`, and `/home` are mounted read/write. +- `/` is mounted read-only at `/host` for inspection. +- `hostexec` can start a temporary privileged helper container and run explicit commands in the host namespaces. + +Only install this on machines where the PI WEB user, the selected workspaces, and the browser/API clients are trusted. Review scripts before piping them to `sh` if you do not already trust this repository. + +The web port is bound to `127.0.0.1` by default. Do **not** expose PI WEB directly to the public internet. For remote access, use one of: + +- an SSH tunnel; +- a VPN/private network address such as Tailscale, NetBird, or WireGuard; +- an authenticated reverse proxy that you operate and trust. + +## Runtime install/update + +Prerequisites: + +- Docker Engine with the Compose plugin (`docker compose`) or `docker-compose`; +- a user that can talk to the Docker daemon; +- `curl` or `wget` for the one-liner installer. + +Install or update with the same command: + +```bash +curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh +``` + +The one-liner is idempotent. Each run refreshes Docker assets from the requested Git ref, writes host-specific `.env` values, rebuilds the local image from npm with `--pull --no-cache`, and recreates the split services without deleting persistent data. + +Defaults: + +- install directory: `~/.local/share/pi-web-docker` (or `$XDG_DATA_HOME/pi-web-docker`); +- persistent data: `/data`, mounted at `/data`; +- browser URL: ; +- npm packages: latest `@jmfederico/pi-web` and latest Pi Coding Agent package unless pinned. + +Updating recreates the Docker `sessiond` container. Active Pi agent runtimes in this Docker install may stop, so update while sessions are idle. Persisted PI WEB state, Pi config, and session history under the data directory are kept. + +Useful runtime commands: + +```bash +cd ~/.local/share/pi-web-docker + +docker compose ps +docker compose logs -f web +docker compose logs -f sessiond +docker compose restart web +docker compose restart sessiond +``` + +To stop the runtime without deleting data: + +```bash +cd ~/.local/share/pi-web-docker +docker compose down +``` + +Do not run `docker compose down -v` unless you intentionally want to remove Compose-managed volumes. The default persistent PI WEB data is a bind mount, but avoiding `-v` keeps the update/stop flow conservative. + +### Installer options + +The installer accepts flags and equivalent environment variables: + +```bash +curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh \ + | sh -s -- \ + --install-dir ~/.local/share/pi-web-docker \ + --data-dir ~/.local/share/pi-web-docker/data \ + --bind-address 127.0.0.1 \ + --port 8504 \ + --pi-web-version latest \ + --pi-version latest +``` + +Common environment variables written to `.env`: + +| Variable | Purpose | +| --- | --- | +| `PI_WEB_UID`, `PI_WEB_GID` | user/group used by the runtime containers | +| `DOCKER_GID` | extra group used for Docker socket access | +| `PI_WEB_DOCKER_DATA_DIR` | persistent data bind mount | +| `PI_WEB_BIND_ADDR`, `PI_WEB_PORT` | host bind address and port | +| `PI_WEB_VERSION` | npm version/range for `@jmfederico/pi-web` | +| `PI_VERSION` | npm version/range for `@earendil-works/pi-coding-agent` | +| `PI_WEB_IMAGE` | local image tag to build and run | +| `HOSTEXEC_IMAGE` | helper image used by `hostexec` | + +Host-derived IDs are refreshed on rerun unless you explicitly override them. User-facing values such as data directory, bind address, port, image names, upload limit, and version pins are preserved from an existing `.env` unless you pass a flag or environment override. + +### Version pinning + +Pin npm package versions when you want repeatable rebuilds: + +```bash +curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh \ + | sh -s -- --pi-web-version 1.202606.4 --pi-version 0.79.1 +``` + +You can also edit `.env` in the install directory: + +```dotenv +PI_WEB_VERSION=1.202606.4 +PI_VERSION=0.79.1 +``` + +Then rerun the one-liner to rebuild/recreate with those pins. Use `latest` again when you want the runtime to track the newest npm releases. + +To pin the Docker asset templates themselves, fetch the installer from a specific Git branch, tag, or commit and pass the same ref as the asset source: + +```bash +ref= +curl -fsSL "https://raw.githubusercontent.com/jmfederico/pi-web/$ref/docker/install.sh" \ + | sh -s -- --asset-ref "$ref" +``` + +## Localhost binding and remote access + +The runtime listens on `0.0.0.0:8504` inside the container but publishes it to `127.0.0.1:8504` on the host by default. + +For SSH access from your laptop: + +```bash +ssh -L 8504:127.0.0.1:8504 user@server +# open http://127.0.0.1:8504 locally +``` + +For a trusted VPN/private interface, bind to that private address: + +```bash +curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh \ + | sh -s -- --bind-address 100.x.y.z --port 8504 +``` + +If you use a reverse proxy, keep the container bound to localhost or a private address and put authentication/TLS at the proxy. Avoid `--bind-address 0.0.0.0` unless another trusted layer restricts access. + +## `hostexec` examples + +`hostexec ` is the only host command bridge provided by this Docker setup. It intentionally does not abstract package managers or detect distributions. + +Run it from a PI WEB session, a PI WEB terminal, or by execing into the runtime container: + +```bash +hostexec uname -a +hostexec systemctl status docker +hostexec zypper refresh +hostexec sh -lc 'zypper refresh && zypper dup -y' +hostexec apt-get update +``` + +From the host shell, for a quick smoke test: + +```bash +cd ~/.local/share/pi-web-docker +docker compose exec web hostexec uname -a +``` + +`hostexec` starts a temporary privileged helper container through the mounted Docker socket, enters the host namespaces with `nsenter`, and runs exactly the command you passed. Treat it like running a privileged host command. + +## Development Docker setup + +Use this mode when developing PI WEB from this checkout. It bind-mounts the source tree, keeps dependencies and PI WEB data in Docker volumes, and preserves the split runtime model: + +- `sessiond` runs `npm run start:sessiond` as the long-lived owner of Pi agent runtimes; +- `web` runs `npm run dev:web` and `npm run dev:client` so API, plugin, and Vite changes can autoreload without restarting `sessiond`. + +From the repository root: + +```bash +export PI_WEB_UID=$(id -u) +export PI_WEB_GID=$(id -g) +export DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) + +docker compose -f docker/compose.dev.yml up --build +``` + +Open the Vite UI at . The dev API is published on . + +Useful development commands: + +```bash +docker compose -f docker/compose.dev.yml ps +docker compose -f docker/compose.dev.yml logs -f web +docker compose -f docker/compose.dev.yml restart web +docker compose -f docker/compose.dev.yml restart sessiond +docker compose -f docker/compose.dev.yml down +``` + +Restart `sessiond` manually after changes that affect `src/server/sessiond.ts`, daemon ownership, or session-daemon-only code paths. Restarting only `web` is enough for ordinary API/client/plugin development reloads. + +The dev setup intentionally has the same Docker socket and broad host mounts as the runtime setup. The same trust warnings apply. + +When `package-lock.json` changes, rebuild the dev image and recreate the `node_modules` volume so the bind-mounted checkout sees the new dependency tree: + +```bash +docker compose -f docker/compose.dev.yml down +docker volume rm pi-web-dev_node_modules +docker compose -f docker/compose.dev.yml up --build +``` + +## Local checkout validation + +For installer validation from a checkout without starting containers: + +```bash +PI_WEB_DOCKER_SKIP_COMPOSE=1 \ +PI_WEB_DOCKER_ASSET_DIR="$PWD/docker" \ +PI_WEB_DOCKER_HOME="$(mktemp -d)" \ +sh docker/install.sh +``` + +For Compose validation: + +```bash +docker compose -f docker/compose.yml config +docker compose -f docker/compose.dev.yml config +docker build --check -f docker/Dockerfile docker +docker build --check -f docker/Dockerfile.dev . +``` From a0d60215ca98bf75a268f4a8908c75295886d628 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 20 Jun 2026 23:28:35 +0200 Subject: [PATCH 006/111] feat(docker): share dev data with runtime --- .changeset/docker-shared-dev-data.md | 5 +++++ README.md | 2 +- docker/README.md | 32 +++++++++++++++++++++++++++- docker/compose.dev.yml | 5 ++--- 4 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 .changeset/docker-shared-dev-data.md diff --git a/.changeset/docker-shared-dev-data.md b/.changeset/docker-shared-dev-data.md new file mode 100644 index 0000000..3ab67da --- /dev/null +++ b/.changeset/docker-shared-dev-data.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Share the Docker development data mount with the runtime Docker data directory by default so Pi sessions can be reused across modes. diff --git a/README.md b/README.md index 3ba2682..61ad3ba 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ export DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) docker compose -f docker/compose.dev.yml up --build ``` -Open . The Docker dev setup keeps `sessiond` separate from the autoreloading web/API/client service. See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md#development-docker-setup). +Open . The Docker dev setup keeps `sessiond` separate from the autoreloading web/API/client service and uses the runtime Docker data directory by default so sessions can be shared across modes. See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md#development-docker-setup). ## Production-style run from a checkout diff --git a/docker/README.md b/docker/README.md index f3440aa..2eab487 100644 --- a/docker/README.md +++ b/docker/README.md @@ -173,7 +173,7 @@ docker compose exec web hostexec uname -a ## Development Docker setup -Use this mode when developing PI WEB from this checkout. It bind-mounts the source tree, keeps dependencies and PI WEB data in Docker volumes, and preserves the split runtime model: +Use this mode when developing PI WEB from this checkout. It bind-mounts the source tree, keeps dependencies in a Docker volume, stores PI WEB/Pi data in the same host data directory as runtime mode by default, and preserves the split runtime model: - `sessiond` runs `npm run start:sessiond` as the long-lived owner of Pi agent runtimes; - `web` runs `npm run dev:web` and `npm run dev:client` so API, plugin, and Vite changes can autoreload without restarting `sessiond`. @@ -184,10 +184,20 @@ From the repository root: export PI_WEB_UID=$(id -u) export PI_WEB_GID=$(id -g) export DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) +# Optional; this is also the default dev data path. +export PI_WEB_DOCKER_DATA_DIR=${PI_WEB_DOCKER_DATA_DIR:-$HOME/.local/share/pi-web-docker/data} +mkdir -p "$PI_WEB_DOCKER_DATA_DIR" docker compose -f docker/compose.dev.yml up --build ``` +If you already ran the runtime installer, you can reuse its `.env` so dev mode gets the same UID/GID, Docker group, ports, and data directory: + +```bash +docker compose --env-file "$HOME/.local/share/pi-web-docker/.env" \ + -f docker/compose.dev.yml up --build +``` + Open the Vite UI at . The dev API is published on . Useful development commands: @@ -204,6 +214,26 @@ Restart `sessiond` manually after changes that affect `src/server/sessiond.ts`, The dev setup intentionally has the same Docker socket and broad host mounts as the runtime setup. The same trust warnings apply. +### Sharing runtime and development state + +Runtime and dev mode both use `/data` inside the containers. By default they now point at the same host directory: + +```text +$HOME/.local/share/pi-web-docker/data +``` + +Pi session files are therefore shared at: + +```text +$HOME/.local/share/pi-web-docker/data/pi-agent/sessions/ +``` + +Set `PI_WEB_DOCKER_DATA_DIR=/some/path` for both modes if you want that shared data somewhere else. + +Use this shared directory to switch between runtime and dev mode, not to run both at the same time. Stop one Compose stack before starting the other so two session daemons do not share the same socket/state directory concurrently. + +For sessions to appear under the same workspace in both modes, use the same project path in PI WEB. On Flatcar, prefer host-mounted paths such as `/home/core/`, `/srv/`, or `/opt/`. The dev container also exposes this checkout as `/workspace` so the PI WEB dev server can run from it, but sessions started against `/workspace` are organized under that different working-directory path and will not line up with runtime sessions for `/home/core/`. + When `package-lock.json` changes, rebuild the dev image and recreate the `node_modules` volume so the bind-mounted checkout sees the new dependency tree: ```bash diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index c85fe2a..b5359dc 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -22,8 +22,8 @@ x-pi-web-dev-volumes: &pi-web-dev-volumes - type: volume source: node_modules target: /workspace/node_modules - - type: volume - source: data + - type: bind + source: ${PI_WEB_DOCKER_DATA_DIR:-${HOME}/.local/share/pi-web-docker/data} target: /data - type: bind source: /var/run/docker.sock @@ -91,5 +91,4 @@ services: start_period: 10s volumes: - data: node_modules: From 3e52939aade4333a4fbbc7fdc566e8ee777dd19c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 20 Jun 2026 23:45:04 +0200 Subject: [PATCH 007/111] fix(docker): initialize dev data permissions --- .changeset/docker-dev-data-permissions.md | 5 ++++ docker/README.md | 2 ++ docker/compose.dev.yml | 31 ++++++++++++++++++++--- 3 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 .changeset/docker-dev-data-permissions.md diff --git a/.changeset/docker-dev-data-permissions.md b/.changeset/docker-dev-data-permissions.md new file mode 100644 index 0000000..6a0b7ea --- /dev/null +++ b/.changeset/docker-dev-data-permissions.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Initialize shared Docker development data directory ownership before starting the dev session daemon. diff --git a/docker/README.md b/docker/README.md index 2eab487..68e1f4b 100644 --- a/docker/README.md +++ b/docker/README.md @@ -214,6 +214,8 @@ Restart `sessiond` manually after changes that affect `src/server/sessiond.ts`, The dev setup intentionally has the same Docker socket and broad host mounts as the runtime setup. The same trust warnings apply. +On startup, a short `data-init` service creates the shared `/data` subdirectories and gives them to `PI_WEB_UID:PI_WEB_GID`. This handles the common Flatcar/Docker case where a missing bind-mount directory is created as root by the Docker daemon. + ### Sharing runtime and development state Runtime and dev mode both use `/data` inside the containers. By default they now point at the same host directory: diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index b5359dc..f96cdf7 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -15,6 +15,11 @@ x-pi-web-dev-environment: &pi-web-dev-environment NPM_CONFIG_UPDATE_NOTIFIER: "false" NPM_CONFIG_CACHE: /data/npm-cache +x-pi-web-dev-data-volume: &pi-web-dev-data-volume + type: bind + source: ${PI_WEB_DOCKER_DATA_DIR:-${HOME}/.local/share/pi-web-docker/data} + target: /data + x-pi-web-dev-volumes: &pi-web-dev-volumes - type: bind source: .. @@ -22,9 +27,7 @@ x-pi-web-dev-volumes: &pi-web-dev-volumes - type: volume source: node_modules target: /workspace/node_modules - - type: bind - source: ${PI_WEB_DOCKER_DATA_DIR:-${HOME}/.local/share/pi-web-docker/data} - target: /data + - *pi-web-dev-data-volume - type: bind source: /var/run/docker.sock target: /var/run/docker.sock @@ -43,11 +46,33 @@ x-pi-web-dev-volumes: &pi-web-dev-volumes read_only: true services: + data-init: + build: *pi-web-dev-build + image: ${PI_WEB_DEV_IMAGE:-pi-web:dev} + command: + - bash + - -lc + - | + set -euo pipefail + mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent + chown -R "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}" /data + user: "0:0" + security_opt: + - label=disable + environment: + PI_WEB_UID: ${PI_WEB_UID:-1000} + PI_WEB_GID: ${PI_WEB_GID:-1000} + volumes: + - *pi-web-dev-data-volume + sessiond: build: *pi-web-dev-build image: ${PI_WEB_DEV_IMAGE:-pi-web:dev} command: ["npm", "run", "start:sessiond"] working_dir: /workspace + depends_on: + data-init: + condition: service_completed_successfully user: "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}" group_add: - "${DOCKER_GID:-0}" From 92e87ff1e45007f5e601e8f629fce77077dad5e5 Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Sun, 21 Jun 2026 17:40:45 +0000 Subject: [PATCH 008/111] fix(docker): run hostexec as container user --- .changeset/hostexec-container-user.md | 5 ++ docker/README.md | 12 ++-- docker/bin/hostexec | 95 ++++++++++++++++++++++++--- 3 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 .changeset/hostexec-container-user.md diff --git a/.changeset/hostexec-container-user.md b/.changeset/hostexec-container-user.md new file mode 100644 index 0000000..6302643 --- /dev/null +++ b/.changeset/hostexec-container-user.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Run Docker host command bridge commands as the PI WEB container user by default, with `hostexec --root` for administrative commands. diff --git a/docker/README.md b/docker/README.md index 68e1f4b..f66606f 100644 --- a/docker/README.md +++ b/docker/README.md @@ -16,7 +16,7 @@ By design, the runtime containers get deliberate host access so PI WEB agents ca - `/var/run/docker.sock` is mounted into the containers. The Docker socket is root-equivalent on the host. - `/srv`, `/opt`, and `/home` are mounted read/write. - `/` is mounted read-only at `/host` for inspection. -- `hostexec` can start a temporary privileged helper container and run explicit commands in the host namespaces. +- `hostexec` can start a temporary privileged helper container and run explicit commands in the host namespaces. Commands run as the container user by default, and `hostexec --root` can still run explicit administrative commands as root. Only install this on machines where the PI WEB user, the selected workspaces, and the browser/API clients are trusted. Review scripts before piping them to `sh` if you do not already trust this repository. @@ -150,16 +150,16 @@ If you use a reverse proxy, keep the container bound to localhost or a private a ## `hostexec` examples -`hostexec ` is the only host command bridge provided by this Docker setup. It intentionally does not abstract package managers or detect distributions. +`hostexec [--root] ` is the only host command bridge provided by this Docker setup. It intentionally does not abstract package managers or detect distributions. By default, commands run as the same numeric user/group as the PI WEB container. Use `--root` only for administrative host commands. Run it from a PI WEB session, a PI WEB terminal, or by execing into the runtime container: ```bash hostexec uname -a hostexec systemctl status docker -hostexec zypper refresh -hostexec sh -lc 'zypper refresh && zypper dup -y' -hostexec apt-get update +hostexec --root zypper refresh +hostexec --root sh -lc 'zypper refresh && zypper dup -y' +hostexec --root apt-get update ``` From the host shell, for a quick smoke test: @@ -169,7 +169,7 @@ cd ~/.local/share/pi-web-docker docker compose exec web hostexec uname -a ``` -`hostexec` starts a temporary privileged helper container through the mounted Docker socket, enters the host namespaces with `nsenter`, and runs exactly the command you passed. Treat it like running a privileged host command. +`hostexec` starts a temporary privileged helper container through the mounted Docker socket, enters the host namespaces with `nsenter`, and runs exactly the command you passed. Treat it like privileged host access even when the final command drops back to the container user. ## Development Docker setup diff --git a/docker/bin/hostexec b/docker/bin/hostexec index f70cc61..c46a3d0 100755 --- a/docker/bin/hostexec +++ b/docker/bin/hostexec @@ -3,13 +3,36 @@ set -euo pipefail usage() { cat >&2 <<'EOF' -Usage: hostexec +Usage: hostexec [--root] [--] Run a command on the Docker host by starting a temporary privileged helper container through the mounted Docker socket and entering the host namespaces. +Commands run as the current container UID/GID by default. Use --root to keep +root privileges for administrative host commands. EOF } +run_as_root=false +while [ "$#" -gt 0 ]; do + case "$1" in + --root) + run_as_root=true + shift + ;; + --help|-h) + usage + exit 0 + ;; + --) + shift + break + ;; + *) + break + ;; + esac +done + if [ "$#" -eq 0 ]; then usage exit 64 @@ -30,19 +53,71 @@ if [[ "$docker_host" == unix://* ]]; then fi helper_image="${HOSTEXEC_IMAGE:-alpine:3.22}" +target_uid="$(id -u)" +target_gid="$(id -g)" tty_args=(--interactive) if [ -t 0 ] && [ -t 1 ]; then tty_args+=(--tty) fi +docker_args=( + --rm + "${tty_args[@]}" + --pull=missing + --privileged + --security-opt label=disable + --pid=host + --network=host + --volume /:/host:rw +) + +if [ "$run_as_root" = true ] || { [ "$target_uid" = 0 ] && [ "$target_gid" = 0 ]; }; then + exec docker run \ + "${docker_args[@]}" \ + "$helper_image" \ + nsenter -t 1 -m -u -i -n -p -- "$@" +fi + +run_as_container_user='target_uid="${HOSTEXEC_TARGET_UID:?}" +target_gid="${HOSTEXEC_TARGET_GID:?}" + +target_user="" +if command -v getent >/dev/null 2>&1; then + passwd_entry="$(getent passwd "$target_uid" || true)" + if [ -n "$passwd_entry" ]; then + target_user="${passwd_entry%%:*}" + fi +fi + +if [ -n "$target_user" ]; then + if command -v runuser >/dev/null 2>&1; then + exec runuser -u "$target_user" -- "$@" + fi + + if command -v su >/dev/null 2>&1; then + exec su -s /bin/sh -c '\''exec "$@"'\'' -- "$target_user" hostexec-su "$@" + fi +fi + +if command -v setpriv >/dev/null 2>&1; then + if [ -n "$target_user" ]; then + exec setpriv --reuid "$target_uid" --regid "$target_gid" --init-groups -- "$@" + fi + + exec setpriv --reuid "$target_uid" --regid "$target_gid" --clear-groups -- "$@" +fi + +if command -v nsenter >/dev/null 2>&1; then + exec nsenter -t 1 -m -u -i -n -p -S "$target_uid" -G "$target_gid" -- "$@" +fi + +echo "hostexec: unable to switch to host uid:gid $target_uid:$target_gid" >&2 +exit 69 +' + exec docker run \ - --rm \ - "${tty_args[@]}" \ - --pull=missing \ - --privileged \ - --security-opt label=disable \ - --pid=host \ - --network=host \ - --volume /:/host:rw \ + "${docker_args[@]}" \ + --env HOSTEXEC_TARGET_UID="$target_uid" \ + --env HOSTEXEC_TARGET_GID="$target_gid" \ "$helper_image" \ - nsenter -t 1 -m -u -i -n -p -- "$@" + nsenter -t 1 -m -u -i -n -p -- /bin/sh -c "$run_as_container_user" hostexec-user "$@" From 784a27a7acc6450bc5646ddf5bb06767b7c5996f Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Sun, 21 Jun 2026 17:41:27 +0000 Subject: [PATCH 009/111] feat(docker): add custom image hooks --- .changeset/docker-custom-image-hooks.md | 5 +++++ .dockerignore | 4 ++++ .gitignore | 4 ++++ README.md | 2 +- docker/.dockerignore | 3 +++ docker/Dockerfile | 10 +++++++++ docker/Dockerfile.dev | 10 +++++++++ docker/README.md | 28 +++++++++++++++++++++++++ docker/custom-image.d/.gitkeep | 0 docker/install.sh | 7 +++++++ 10 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 .changeset/docker-custom-image-hooks.md create mode 100644 docker/custom-image.d/.gitkeep diff --git a/.changeset/docker-custom-image-hooks.md b/.changeset/docker-custom-image-hooks.md new file mode 100644 index 0000000..bda892a --- /dev/null +++ b/.changeset/docker-custom-image-hooks.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add Docker custom image hooks so local installs can add optional CLIs without bloating the default image. diff --git a/.dockerignore b/.dockerignore index 34f60e9..db14171 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,7 @@ dev-plugins *.log .env .DS_Store + +docker/custom-image.d/* +!docker/custom-image.d/.gitkeep +!docker/custom-image.d/*.sh diff --git a/.gitignore b/.gitignore index 052ea5e..afb4be8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,9 @@ dist/ # Local plugin development sandboxes. Symlink these into ~/.pi-web/plugins/. /dev-plugins/ +# Local Docker image build hooks for development containers. +/docker/custom-image.d/* +!/docker/custom-image.d/.gitkeep + # Local runtime attachment uploads (created by the chat composer "save to folder" mode). .pi-web/ diff --git a/README.md b/README.md index 61ad3ba..b924b0c 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/insta It builds a local image from npm, runs split `sessiond` and `web` services, binds the browser UI to `127.0.0.1:8504` by default, and uses the same command as the update path. The Docker setup intentionally mounts the Docker socket and selected host paths; treat it as root-equivalent host access, do not expose it directly to the public internet, and use an SSH tunnel, VPN, or authenticated reverse proxy for remote access. -See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, host command examples, and development Compose usage. +See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, custom image hooks for optional CLIs, host command examples, and development Compose usage. PI WEB is also published as a Pi package. Installing it through Pi exposes a `/pi-web` command inside Pi: diff --git a/docker/.dockerignore b/docker/.dockerignore index 1bb8da0..872b011 100644 --- a/docker/.dockerignore +++ b/docker/.dockerignore @@ -3,3 +3,6 @@ !Dockerfile !bin/ !bin/hostexec +!custom-image.d/ +!custom-image.d/.gitkeep +!custom-image.d/*.sh diff --git a/docker/Dockerfile b/docker/Dockerfile index 787aad0..02bde6d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -52,6 +52,16 @@ RUN apt-get update \ && mkdir -p /data/home /data/config /data/pi-web /data/pi-agent /workspace \ && chown -R node:node /data /workspace +COPY custom-image.d/ /tmp/pi-web-custom-image.d/ +RUN bash -euxo pipefail -c '\ + shopt -s nullglob; \ + for script in /tmp/pi-web-custom-image.d/*.sh; do \ + echo "Running PI WEB custom image hook: ${script}"; \ + bash "${script}"; \ + done; \ + rm -rf /tmp/pi-web-custom-image.d /var/lib/apt/lists/* \ +' + COPY --from=package /usr/local/lib/node_modules /usr/local/lib/node_modules COPY --from=package /usr/local/bin /usr/local/bin COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index cdd5afc..bcdcd31 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -46,6 +46,16 @@ RUN npm ci \ COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker COPY --chmod=0755 docker/bin/hostexec /usr/local/bin/hostexec +COPY docker/custom-image.d/ /tmp/pi-web-custom-image.d/ +RUN bash -euxo pipefail -c '\ + shopt -s nullglob; \ + for script in /tmp/pi-web-custom-image.d/*.sh; do \ + echo "Running PI WEB custom image hook: ${script}"; \ + bash "${script}"; \ + done; \ + rm -rf /tmp/pi-web-custom-image.d /var/lib/apt/lists/* \ +' + EXPOSE 8504 8505 ENTRYPOINT ["tini", "--"] diff --git a/docker/README.md b/docker/README.md index f66606f..419128a 100644 --- a/docker/README.md +++ b/docker/README.md @@ -102,6 +102,34 @@ Common environment variables written to `.env`: Host-derived IDs are refreshed on rerun unless you explicitly override them. User-facing values such as data directory, bind address, port, image names, upload limit, and version pins are preserved from an existing `.env` unless you pass a flag or environment override. +### Custom image hooks + +The runtime image can be extended without changing PI WEB's Dockerfile. Put local Bash scripts ending in `.sh` under: + +```text +~/.local/share/pi-web-docker/custom-image.d/ +``` + +The installer preserves that directory, includes the `*.sh` files in the Docker build context, and runs each script as `root` during the image build in lexical order. Use this for optional tools such as `gh`, `glab`, `kubectl`, or cloud CLIs that you do not want in the default image. + +Example: + +```bash +mkdir -p ~/.local/share/pi-web-docker/custom-image.d +$EDITOR ~/.local/share/pi-web-docker/custom-image.d/10-github-cli.sh +curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh +``` + +Keep credentials out of these scripts. Authenticate tools after the container starts so secrets live in the persistent `/data` mount, for example through `/data/home` and `/data/config`. + +For Docker development from this checkout, use the equivalent local directory: + +```text +docker/custom-image.d/ +``` + +Files in that development hook directory are ignored by Git except for the placeholder that keeps the directory available to Docker builds. + ### Version pinning Pin npm package versions when you want repeatable rebuilds: diff --git a/docker/custom-image.d/.gitkeep b/docker/custom-image.d/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker/install.sh b/docker/install.sh index 6800042..27fc2fb 100755 --- a/docker/install.sh +++ b/docker/install.sh @@ -306,6 +306,12 @@ write_asset .dockerignore 0644 write_asset install.sh 0755 write_asset bin/hostexec 0755 +custom_image_hooks_dir=$install_dir/custom-image.d +mkdir -p "$custom_image_hooks_dir" || die "could not create custom image hooks directory: $custom_image_hooks_dir" +if [ ! -e "$custom_image_hooks_dir/.gitkeep" ]; then + : >"$custom_image_hooks_dir/.gitkeep" || die "could not initialize custom image hooks directory: $custom_image_hooks_dir" +fi + pi_web_uid=$(value_from_env_or_default PI_WEB_UID "$(id -u)") pi_web_gid=$(value_from_env_or_default PI_WEB_GID "$(id -g)") docker_gid=$(value_from_env_or_default DOCKER_GID "$(detect_docker_gid)") @@ -364,6 +370,7 @@ mv "$temp_env" "$env_file" log "Wrote Docker assets to $install_dir" log "Wrote runtime environment to $env_file" log "Persistent PI WEB Docker data: $data_dir" +log "Custom image hooks: $custom_image_hooks_dir" if [ "${PI_WEB_DOCKER_SKIP_COMPOSE:-0}" = 1 ]; then log "Skipping Docker build/recreate because PI_WEB_DOCKER_SKIP_COMPOSE=1" From f8982a9947b106ddf68ed56bc944f75d1f859971 Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Sun, 21 Jun 2026 22:17:30 +0000 Subject: [PATCH 010/111] feat(docker): migrate images to openSUSE Tumbleweed --- .changeset/opensuse-tumbleweed-docker.md | 5 + README.md | 4 +- docker/.dockerignore | 1 + docker/Dockerfile | 54 ++++----- docker/Dockerfile.dev | 36 +++--- docker/README.md | 30 ++++- docker/bin/install-opensuse-base | 135 +++++++++++++++++++++++ docker/compose.dev.yml | 5 + docker/compose.yml | 4 + docker/install.sh | 48 ++++++++ 10 files changed, 272 insertions(+), 50 deletions(-) create mode 100644 .changeset/opensuse-tumbleweed-docker.md create mode 100755 docker/bin/install-opensuse-base diff --git a/.changeset/opensuse-tumbleweed-docker.md b/.changeset/opensuse-tumbleweed-docker.md new file mode 100644 index 0000000..387bb50 --- /dev/null +++ b/.changeset/opensuse-tumbleweed-docker.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Move the Docker runtime and development images to openSUSE Tumbleweed with Node.js 22, npx, Corepack, and common development tooling, plus zypper-based package customization. diff --git a/README.md b/README.md index b924b0c..3e7dda5 100644 --- a/README.md +++ b/README.md @@ -187,9 +187,9 @@ A Docker runtime is available for trusted local/server installs without using pr curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh ``` -It builds a local image from npm, runs split `sessiond` and `web` services, binds the browser UI to `127.0.0.1:8504` by default, and uses the same command as the update path. The Docker setup intentionally mounts the Docker socket and selected host paths; treat it as root-equivalent host access, do not expose it directly to the public internet, and use an SSH tunnel, VPN, or authenticated reverse proxy for remote access. +It builds an openSUSE Tumbleweed based local image from npm with Node.js 22, npx, Corepack, and common development/agent tooling, runs split `sessiond` and `web` services, binds the browser UI to `127.0.0.1:8504` by default, and uses the same command as the update path. The Docker setup intentionally mounts the Docker socket and selected host paths; treat it as root-equivalent host access, do not expose it directly to the public internet, and use an SSH tunnel, VPN, or authenticated reverse proxy for remote access. -See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, custom image hooks for optional CLIs, host command examples, and development Compose usage. +See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, openSUSE package customization, custom image hooks for optional CLIs, host command examples, and development Compose usage. PI WEB is also published as a Pi package. Installing it through Pi exposes a `/pi-web` command inside Pi: diff --git a/docker/.dockerignore b/docker/.dockerignore index 872b011..8a7307a 100644 --- a/docker/.dockerignore +++ b/docker/.dockerignore @@ -3,6 +3,7 @@ !Dockerfile !bin/ !bin/hostexec +!bin/install-opensuse-base !custom-image.d/ !custom-image.d/.gitkeep !custom-image.d/*.sh diff --git a/docker/Dockerfile b/docker/Dockerfile index 02bde6d..c411301 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,22 +1,32 @@ # syntax=docker/dockerfile:1.7 -ARG NODE_VERSION=22-bookworm-slim +ARG OPENSUSE_IMAGE=opensuse/tumbleweed ARG DOCKER_CLI_VERSION=29-cli FROM docker:${DOCKER_CLI_VERSION} AS docker-cli -FROM node:${NODE_VERSION} AS package +FROM ${OPENSUSE_IMAGE} AS base + +ARG NODEJS_MAJOR=22 +ARG NODEJS_REPO=auto +ARG PI_WEB_EXTRA_ZYPPER_PACKAGES="" + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +ENV NPM_CONFIG_UPDATE_NOTIFIER=false \ + SHELL=/bin/bash \ + TERM=xterm-256color + +COPY bin/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base +RUN chmod 0755 /usr/local/sbin/install-pi-web-opensuse-base \ + && install-pi-web-opensuse-base + +FROM base AS package ARG PI_WEB_VERSION=latest ARG PI_VERSION=latest ARG CACHE_BUST=local -ENV NPM_CONFIG_UPDATE_NOTIFIER=false - -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates g++ make python3 \ - && rm -rf /var/lib/apt/lists/* - RUN set -eux; \ echo "PI WEB Docker build cache bust: ${CACHE_BUST}"; \ npm install -g --omit=dev --no-audit --no-fund \ @@ -24,7 +34,7 @@ RUN set -eux; \ "@earendil-works/pi-coding-agent@${PI_VERSION}"; \ npm cache clean --force -FROM node:${NODE_VERSION} AS runtime +FROM base AS runtime ENV NODE_ENV=production \ NPM_CONFIG_UPDATE_NOTIFIER=false \ @@ -39,18 +49,11 @@ ENV NODE_ENV=production \ SHELL=/bin/bash \ TERM=xterm-256color -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - bash \ - ca-certificates \ - curl \ - git \ - openssh-client \ - procps \ - tini \ - && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /data/home /data/config /data/pi-web /data/pi-agent /workspace \ - && chown -R node:node /data /workspace +COPY --from=package /usr/local/lib/node_modules /usr/local/lib/node_modules +COPY --from=package /usr/local/bin /usr/local/bin +COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker +COPY bin/hostexec /usr/local/bin/hostexec +RUN chmod 0755 /usr/local/bin/hostexec COPY custom-image.d/ /tmp/pi-web-custom-image.d/ RUN bash -euxo pipefail -c '\ @@ -59,14 +62,11 @@ RUN bash -euxo pipefail -c '\ echo "Running PI WEB custom image hook: ${script}"; \ bash "${script}"; \ done; \ - rm -rf /tmp/pi-web-custom-image.d /var/lib/apt/lists/* \ + rm -rf /tmp/pi-web-custom-image.d; \ + zypper clean --all; \ + rm -rf /var/cache/zypp/* \ ' -COPY --from=package /usr/local/lib/node_modules /usr/local/lib/node_modules -COPY --from=package /usr/local/bin /usr/local/bin -COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker -COPY --chmod=0755 bin/hostexec /usr/local/bin/hostexec - WORKDIR /workspace USER node diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index bcdcd31..a1a9608 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -1,13 +1,20 @@ # syntax=docker/dockerfile:1.7 -ARG NODE_VERSION=22-bookworm-slim +ARG OPENSUSE_IMAGE=opensuse/tumbleweed ARG DOCKER_CLI_VERSION=29-cli FROM docker:${DOCKER_CLI_VERSION} AS docker-cli -FROM node:${NODE_VERSION} AS dev +FROM ${OPENSUSE_IMAGE} AS dev + +ARG NODEJS_MAJOR=22 +ARG NODEJS_REPO=auto +ARG PI_WEB_EXTRA_ZYPPER_PACKAGES="" + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] ENV NODE_ENV=development \ + PATH=/workspace/node_modules/.bin:$PATH \ NPM_CONFIG_UPDATE_NOTIFIER=false \ NPM_CONFIG_CACHE=/data/npm-cache \ HOME=/data/home \ @@ -19,32 +26,23 @@ ENV NODE_ENV=development \ SHELL=/bin/bash \ TERM=xterm-256color -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - bash \ - ca-certificates \ - curl \ - g++ \ - git \ - make \ - openssh-client \ - procps \ - python3 \ - tini \ - && rm -rf /var/lib/apt/lists/* +COPY docker/bin/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base +RUN chmod 0755 /usr/local/sbin/install-pi-web-opensuse-base \ + && install-pi-web-opensuse-base WORKDIR /workspace COPY package.json package-lock.json ./ COPY scripts/install-git-hooks.mjs scripts/install-git-hooks.mjs RUN npm ci \ + && ln -sf /workspace/node_modules/.bin/pi /usr/local/bin/pi \ && npm cache clean --force \ - && mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent \ && chmod -R a+rwX /workspace/node_modules /data \ && chmod 0777 /workspace COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker -COPY --chmod=0755 docker/bin/hostexec /usr/local/bin/hostexec +COPY docker/bin/hostexec /usr/local/bin/hostexec +RUN chmod 0755 /usr/local/bin/hostexec COPY docker/custom-image.d/ /tmp/pi-web-custom-image.d/ RUN bash -euxo pipefail -c '\ @@ -53,7 +51,9 @@ RUN bash -euxo pipefail -c '\ echo "Running PI WEB custom image hook: ${script}"; \ bash "${script}"; \ done; \ - rm -rf /tmp/pi-web-custom-image.d /var/lib/apt/lists/* \ + rm -rf /tmp/pi-web-custom-image.d; \ + zypper clean --all; \ + rm -rf /var/cache/zypp/* \ ' EXPOSE 8504 8505 diff --git a/docker/README.md b/docker/README.md index 419128a..d9bfb74 100644 --- a/docker/README.md +++ b/docker/README.md @@ -97,10 +97,27 @@ Common environment variables written to `.env`: | `PI_WEB_BIND_ADDR`, `PI_WEB_PORT` | host bind address and port | | `PI_WEB_VERSION` | npm version/range for `@jmfederico/pi-web` | | `PI_VERSION` | npm version/range for `@earendil-works/pi-coding-agent` | +| `PI_WEB_OPENSUSE_IMAGE` | openSUSE base image used for the runtime build | +| `PI_WEB_NODEJS_MAJOR` | Node.js major package to install, defaulting to `22` | +| `PI_WEB_NODEJS_REPO` | Node.js zypper repository URL, `auto`, or `disabled` | +| `PI_WEB_EXTRA_ZYPPER_PACKAGES` | extra openSUSE packages installed during the image build | | `PI_WEB_IMAGE` | local image tag to build and run | | `HOSTEXEC_IMAGE` | helper image used by `hostexec` | -Host-derived IDs are refreshed on rerun unless you explicitly override them. User-facing values such as data directory, bind address, port, image names, upload limit, and version pins are preserved from an existing `.env` unless you pass a flag or environment override. +Host-derived IDs are refreshed on rerun unless you explicitly override them. User-facing values such as data directory, bind address, port, image names, upload limit, base image, Node.js settings, extra packages, and version pins are preserved from an existing `.env` unless you pass a flag or environment override. + +### Base image and tooling + +The Docker runtime and development images are openSUSE Tumbleweed based by default. They install Node.js 22, npm, `npx`, and Corepack through zypper, using the openSUSE Node.js build service repository when needed for the selected architecture. The image also includes common agent/development tools such as Git/Git LFS, GitHub CLI, OpenSSH, Python with pip/virtualenv and headers, native build tooling, `jq`, `ripgrep`, `fd`, `fzf`, `bat`, ShellCheck, archive tools, network utilities, and the Docker CLI. + +Install extra distro packages without writing a hook by setting a whitespace-delimited package list: + +```bash +PI_WEB_EXTRA_ZYPPER_PACKAGES="go rustup kubernetes-client" \ + curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh +``` + +You can also pass installer flags such as `--opensuse-image`, `--nodejs-major`, `--nodejs-repo`, and `--extra-zypper-packages`, or edit the generated `.env` and rerun the installer. ### Custom image hooks @@ -110,13 +127,20 @@ The runtime image can be extended without changing PI WEB's Dockerfile. Put loca ~/.local/share/pi-web-docker/custom-image.d/ ``` -The installer preserves that directory, includes the `*.sh` files in the Docker build context, and runs each script as `root` during the image build in lexical order. Use this for optional tools such as `gh`, `glab`, `kubectl`, or cloud CLIs that you do not want in the default image. +The installer preserves that directory, includes the `*.sh` files in the Docker build context, and runs each script as `root` during the image build in lexical order. Use this for optional tools such as `glab`, `kubectl`, cloud CLIs, or language toolchains that you do not want in the default image. Example: ```bash mkdir -p ~/.local/share/pi-web-docker/custom-image.d -$EDITOR ~/.local/share/pi-web-docker/custom-image.d/10-github-cli.sh +cat >~/.local/share/pi-web-docker/custom-image.d/10-extra-tools.sh <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +zypper --gpg-auto-import-keys --non-interactive refresh +zypper --non-interactive install --no-recommends glab kubernetes-client +zypper clean --all +EOF +chmod +x ~/.local/share/pi-web-docker/custom-image.d/10-extra-tools.sh curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh ``` diff --git a/docker/bin/install-opensuse-base b/docker/bin/install-opensuse-base new file mode 100755 index 0000000..7e0adfe --- /dev/null +++ b/docker/bin/install-opensuse-base @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail + +nodejs_major=${NODEJS_MAJOR:-22} +nodejs_repo=${NODEJS_REPO:-auto} +extra_zypper_packages=${PI_WEB_EXTRA_ZYPPER_PACKAGES:-} + +nodejs_repo_flavor() { + local rpm_arch + rpm_arch=$(rpm --eval '%{_target_cpu}') + + case "$rpm_arch" in + aarch64|armv6hl|armv7hl) + printf '%s\n' openSUSE_Factory_ARM + ;; + ppc64le) + printf '%s\n' openSUSE_Factory_PowerPC + ;; + riscv64) + printf '%s\n' openSUSE_Factory_RISCV + ;; + s390x) + printf '%s\n' openSUSE_Factory_zSystems + ;; + *) + printf '%s\n' openSUSE_Tumbleweed + ;; + esac +} + +add_nodejs_repo() { + local repo_url + + case "$nodejs_repo" in + ""|disabled|none) + return 0 + ;; + auto) + repo_url="https://download.opensuse.org/repositories/devel:/languages:/nodejs/$(nodejs_repo_flavor)/" + ;; + *) + repo_url=$nodejs_repo + ;; + esac + + zypper --non-interactive removerepo pi-web-nodejs >/dev/null 2>&1 || true + zypper --non-interactive addrepo --refresh "$repo_url" pi-web-nodejs +} + +# The codec repository is not needed for this image and can make noninteractive +# refreshes noisy or brittle when its signing key rolls independently. +zypper --non-interactive modifyrepo --disable repo-openh264 >/dev/null 2>&1 || true + +add_nodejs_repo +zypper --gpg-auto-import-keys --non-interactive refresh + +packages=( + "nodejs${nodejs_major}" + "npm${nodejs_major}" + "corepack${nodejs_major}" + "nodejs${nodejs_major}-devel" + bash + ca-certificates + curl + wget + git + git-lfs + gh + openssh-clients + procps + tini + shadow + gcc-c++ + make + python3 + python3-devel + python3-pip + python3-virtualenv + jq + ripgrep + fd + fzf + bat + ShellCheck + less + file + which + tar + gzip + xz + unzip + zip + zstd + findutils + grep + sed + gawk + patch + diffutils + util-linux + hostname + iproute2 + bind-utils + rsync +) + +extra_packages=() +if [ -n "$extra_zypper_packages" ]; then + # Intentionally split a whitespace-delimited package list supplied as a Docker + # build arg, e.g. PI_WEB_EXTRA_ZYPPER_PACKAGES="go rustup kubernetes-client". + # shellcheck disable=SC2206 + extra_packages=($extra_zypper_packages) +fi + +zypper --non-interactive install --no-recommends "${packages[@]}" "${extra_packages[@]}" + +node --version +npm --version +npx --version +python3 --version +git --version + +if ! getent group node >/dev/null 2>&1; then + groupadd --gid 1000 node +fi + +if ! id node >/dev/null 2>&1; then + useradd --uid 1000 --gid node --create-home --home-dir /home/node --shell /bin/bash node +fi + +mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent /workspace +chown -R node:node /data /workspace /home/node + +zypper clean --all +rm -rf /var/cache/zypp/* diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index f96cdf7..cf582de 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -3,6 +3,11 @@ name: pi-web-dev x-pi-web-dev-build: &pi-web-dev-build context: .. dockerfile: docker/Dockerfile.dev + args: + OPENSUSE_IMAGE: ${PI_WEB_OPENSUSE_IMAGE:-opensuse/tumbleweed} + NODEJS_MAJOR: ${PI_WEB_NODEJS_MAJOR:-22} + NODEJS_REPO: ${PI_WEB_NODEJS_REPO:-auto} + PI_WEB_EXTRA_ZYPPER_PACKAGES: ${PI_WEB_EXTRA_ZYPPER_PACKAGES:-} x-pi-web-dev-environment: &pi-web-dev-environment HOME: /data/home diff --git a/docker/compose.yml b/docker/compose.yml index b98ac1c..b35a6bd 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -4,6 +4,10 @@ x-pi-web-build: &pi-web-build context: . dockerfile: Dockerfile args: + OPENSUSE_IMAGE: ${PI_WEB_OPENSUSE_IMAGE:-opensuse/tumbleweed} + NODEJS_MAJOR: ${PI_WEB_NODEJS_MAJOR:-22} + NODEJS_REPO: ${PI_WEB_NODEJS_REPO:-auto} + PI_WEB_EXTRA_ZYPPER_PACKAGES: ${PI_WEB_EXTRA_ZYPPER_PACKAGES:-} PI_WEB_VERSION: ${PI_WEB_VERSION:-latest} PI_VERSION: ${PI_VERSION:-latest} CACHE_BUST: ${CACHE_BUST:-local} diff --git a/docker/install.sh b/docker/install.sh index 27fc2fb..5e26fda 100755 --- a/docker/install.sh +++ b/docker/install.sh @@ -28,6 +28,12 @@ Options: --pi-web-version VER npm @jmfederico/pi-web version pin (default: latest) --pi-version VER npm @earendil-works/pi-coding-agent version pin (default: latest) + --opensuse-image IMAGE openSUSE base image (default: opensuse/tumbleweed) + --nodejs-major MAJOR Node.js major version package to install (default: 22) + --nodejs-repo REPO Node.js zypper repository URL, auto, or disabled + (default: auto) + --extra-zypper-packages LIST + extra openSUSE packages to install during image build --asset-dir DIR Copy Docker assets from a local docker/ directory --asset-ref REF Fetch Docker assets from a Git ref (default: main) --skip-compose Write assets/.env but skip build and service recreate @@ -72,6 +78,26 @@ while [ "$#" -gt 0 ]; do PI_VERSION=$2 shift 2 ;; + --opensuse-image) + [ "$#" -ge 2 ] || die "--opensuse-image requires a value" + PI_WEB_OPENSUSE_IMAGE=$2 + shift 2 + ;; + --nodejs-major) + [ "$#" -ge 2 ] || die "--nodejs-major requires a value" + PI_WEB_NODEJS_MAJOR=$2 + shift 2 + ;; + --nodejs-repo) + [ "$#" -ge 2 ] || die "--nodejs-repo requires a value" + PI_WEB_NODEJS_REPO=$2 + shift 2 + ;; + --extra-zypper-packages) + [ "$#" -ge 2 ] || die "--extra-zypper-packages requires a value" + PI_WEB_EXTRA_ZYPPER_PACKAGES=$2 + shift 2 + ;; --asset-dir) [ "$#" -ge 2 ] || die "--asset-dir requires a value" PI_WEB_DOCKER_ASSET_DIR=$2 @@ -191,6 +217,12 @@ require_non_empty() { [ -n "$value" ] || die "$name must not be empty" } +dotenv_quote() { + value=$1 + [ -n "$value" ] || return 0 + printf '"%s"' "$(printf '%s' "$value" | sed 's/[\\"]/\\&/g')" +} + detect_docker_gid() { if [ -S /var/run/docker.sock ]; then if gid=$(stat -c '%g' /var/run/docker.sock 2>/dev/null); then @@ -305,6 +337,7 @@ write_asset compose.yml 0644 write_asset .dockerignore 0644 write_asset install.sh 0755 write_asset bin/hostexec 0755 +write_asset bin/install-opensuse-base 0755 custom_image_hooks_dir=$install_dir/custom-image.d mkdir -p "$custom_image_hooks_dir" || die "could not create custom image hooks directory: $custom_image_hooks_dir" @@ -323,6 +356,10 @@ pi_web_bind_addr=$(value_from_env_or_existing_or_default PI_WEB_BIND_ADDR 127.0. pi_web_port=$(value_from_env_or_existing_or_default PI_WEB_PORT 8504) pi_web_version=$(value_from_env_or_existing_or_default PI_WEB_VERSION latest) pi_version=$(value_from_env_or_existing_or_default PI_VERSION latest) +pi_web_opensuse_image=$(value_from_env_or_existing_or_default PI_WEB_OPENSUSE_IMAGE opensuse/tumbleweed) +pi_web_nodejs_major=$(value_from_env_or_existing_or_default PI_WEB_NODEJS_MAJOR 22) +pi_web_nodejs_repo=$(value_from_env_or_existing_or_default PI_WEB_NODEJS_REPO auto) +pi_web_extra_zypper_packages=$(value_from_env_or_existing_or_default PI_WEB_EXTRA_ZYPPER_PACKAGES "") pi_web_image=$(value_from_env_or_existing_or_default PI_WEB_IMAGE pi-web:local) hostexec_image=$(value_from_env_or_existing_or_default HOSTEXEC_IMAGE alpine:3.22) pi_web_max_upload_bytes=$(value_from_env_or_existing_or_default PI_WEB_MAX_UPLOAD_BYTES 67108864) @@ -335,10 +372,15 @@ require_non_empty PI_WEB_BIND_ADDR "$pi_web_bind_addr" require_non_empty PI_WEB_PORT "$pi_web_port" require_non_empty PI_WEB_VERSION "$pi_web_version" require_non_empty PI_VERSION "$pi_version" +require_non_empty PI_WEB_OPENSUSE_IMAGE "$pi_web_opensuse_image" +require_non_empty PI_WEB_NODEJS_MAJOR "$pi_web_nodejs_major" +require_non_empty PI_WEB_NODEJS_REPO "$pi_web_nodejs_repo" require_non_empty PI_WEB_IMAGE "$pi_web_image" require_non_empty HOSTEXEC_IMAGE "$hostexec_image" require_non_empty PI_WEB_MAX_UPLOAD_BYTES "$pi_web_max_upload_bytes" +pi_web_extra_zypper_packages_env=$(dotenv_quote "$pi_web_extra_zypper_packages") + umask 077 temp_env=$env_file.$$ cat >"$temp_env" < Date: Sun, 21 Jun 2026 22:51:29 +0000 Subject: [PATCH 011/111] fix: name docker runtime user --- .changeset/docker-user-name.md | 5 +++++ docker/Dockerfile | 4 +++- docker/Dockerfile.dev | 2 ++ docker/README.md | 6 +++--- docker/bin/install-opensuse-base | 36 ++++++++++++++++++++++++++------ docker/compose.dev.yml | 2 ++ docker/compose.yml | 2 ++ docker/install.sh | 2 +- 8 files changed, 48 insertions(+), 11 deletions(-) create mode 100644 .changeset/docker-user-name.md diff --git a/.changeset/docker-user-name.md b/.changeset/docker-user-name.md new file mode 100644 index 0000000..cfddfc7 --- /dev/null +++ b/.changeset/docker-user-name.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Create the Docker image `pi-web` user with the configured host UID/GID so container terminals show a normal username instead of `I have no name!`. diff --git a/docker/Dockerfile b/docker/Dockerfile index c411301..bb2b80a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -10,6 +10,8 @@ FROM ${OPENSUSE_IMAGE} AS base ARG NODEJS_MAJOR=22 ARG NODEJS_REPO=auto ARG PI_WEB_EXTRA_ZYPPER_PACKAGES="" +ARG PI_WEB_UID=1000 +ARG PI_WEB_GID=1000 SHELL ["/bin/bash", "-o", "pipefail", "-c"] @@ -68,7 +70,7 @@ RUN bash -euxo pipefail -c '\ ' WORKDIR /workspace -USER node +USER pi-web EXPOSE 8504 diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index a1a9608..f5eda36 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -10,6 +10,8 @@ FROM ${OPENSUSE_IMAGE} AS dev ARG NODEJS_MAJOR=22 ARG NODEJS_REPO=auto ARG PI_WEB_EXTRA_ZYPPER_PACKAGES="" +ARG PI_WEB_UID=1000 +ARG PI_WEB_GID=1000 SHELL ["/bin/bash", "-o", "pipefail", "-c"] diff --git a/docker/README.md b/docker/README.md index d9bfb74..fd9cd75 100644 --- a/docker/README.md +++ b/docker/README.md @@ -91,7 +91,7 @@ Common environment variables written to `.env`: | Variable | Purpose | | --- | --- | -| `PI_WEB_UID`, `PI_WEB_GID` | user/group used by the runtime containers | +| `PI_WEB_UID`, `PI_WEB_GID` | user/group used by the runtime containers and the image's `pi-web` account | | `DOCKER_GID` | extra group used for Docker socket access | | `PI_WEB_DOCKER_DATA_DIR` | persistent data bind mount | | `PI_WEB_BIND_ADDR`, `PI_WEB_PORT` | host bind address and port | @@ -108,7 +108,7 @@ Host-derived IDs are refreshed on rerun unless you explicitly override them. Use ### Base image and tooling -The Docker runtime and development images are openSUSE Tumbleweed based by default. They install Node.js 22, npm, `npx`, and Corepack through zypper, using the openSUSE Node.js build service repository when needed for the selected architecture. The image also includes common agent/development tools such as Git/Git LFS, GitHub CLI, OpenSSH, Python with pip/virtualenv and headers, native build tooling, `jq`, `ripgrep`, `fd`, `fzf`, `bat`, ShellCheck, archive tools, network utilities, and the Docker CLI. +The Docker runtime and development images are openSUSE Tumbleweed based by default. They install Node.js 22, npm, `npx`, and Corepack through zypper, using the openSUSE Node.js build service repository when needed for the selected architecture. The image's `pi-web` account is created with `PI_WEB_UID:PI_WEB_GID`, so shells have a passwd entry instead of showing `I have no name!` when the host user is not `1000:1000`. The image also includes common agent/development tools such as Git/Git LFS, GitHub CLI, OpenSSH, Python with pip/virtualenv and headers, native build tooling, `jq`, `ripgrep`, `fd`, `fzf`, `bat`, ShellCheck, archive tools, network utilities, and the Docker CLI. Install extra distro packages without writing a hook by setting a whitespace-delimited package list: @@ -266,7 +266,7 @@ Restart `sessiond` manually after changes that affect `src/server/sessiond.ts`, The dev setup intentionally has the same Docker socket and broad host mounts as the runtime setup. The same trust warnings apply. -On startup, a short `data-init` service creates the shared `/data` subdirectories and gives them to `PI_WEB_UID:PI_WEB_GID`. This handles the common Flatcar/Docker case where a missing bind-mount directory is created as root by the Docker daemon. +On startup, a short `data-init` service creates the shared `/data` subdirectories and gives them to `PI_WEB_UID:PI_WEB_GID`. This handles the common Flatcar/Docker case where a missing bind-mount directory is created as root by the Docker daemon. Because the image also builds its `pi-web` account with those IDs, rebuild the image if you change `PI_WEB_UID` or `PI_WEB_GID`. ### Sharing runtime and development state diff --git a/docker/bin/install-opensuse-base b/docker/bin/install-opensuse-base index 7e0adfe..fdfb888 100755 --- a/docker/bin/install-opensuse-base +++ b/docker/bin/install-opensuse-base @@ -4,6 +4,8 @@ set -euo pipefail nodejs_major=${NODEJS_MAJOR:-22} nodejs_repo=${NODEJS_REPO:-auto} extra_zypper_packages=${PI_WEB_EXTRA_ZYPPER_PACKAGES:-} +runtime_uid=${PI_WEB_UID:-1000} +runtime_gid=${PI_WEB_GID:-1000} nodejs_repo_flavor() { local rpm_arch @@ -120,16 +122,38 @@ npx --version python3 --version git --version -if ! getent group node >/dev/null 2>&1; then - groupadd --gid 1000 node +case "$runtime_uid" in + ""|*[!0-9]*) + echo "PI_WEB_UID must be a numeric user ID, got: $runtime_uid" >&2 + exit 1 + ;; +esac + +case "$runtime_gid" in + ""|*[!0-9]*) + echo "PI_WEB_GID must be a numeric group ID, got: $runtime_gid" >&2 + exit 1 + ;; +esac + +runtime_user=pi-web +runtime_group=pi-web +if getent group "$runtime_gid" >/dev/null 2>&1; then + runtime_group=$(getent group "$runtime_gid" | cut -d: -f1) +elif getent group "$runtime_group" >/dev/null 2>&1; then + groupmod --gid "$runtime_gid" "$runtime_group" +else + groupadd --gid "$runtime_gid" "$runtime_group" fi -if ! id node >/dev/null 2>&1; then - useradd --uid 1000 --gid node --create-home --home-dir /home/node --shell /bin/bash node +if id "$runtime_user" >/dev/null 2>&1; then + usermod --non-unique --uid "$runtime_uid" --gid "$runtime_group" --home "/home/$runtime_user" --shell /bin/bash "$runtime_user" +else + useradd --non-unique --uid "$runtime_uid" --gid "$runtime_group" --create-home --home-dir "/home/$runtime_user" --shell /bin/bash "$runtime_user" fi -mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent /workspace -chown -R node:node /data /workspace /home/node +mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent /workspace "/home/$runtime_user" +chown -R "$runtime_uid:$runtime_gid" /data /workspace "/home/$runtime_user" zypper clean --all rm -rf /var/cache/zypp/* diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index cf582de..fec1abc 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -8,6 +8,8 @@ x-pi-web-dev-build: &pi-web-dev-build NODEJS_MAJOR: ${PI_WEB_NODEJS_MAJOR:-22} NODEJS_REPO: ${PI_WEB_NODEJS_REPO:-auto} PI_WEB_EXTRA_ZYPPER_PACKAGES: ${PI_WEB_EXTRA_ZYPPER_PACKAGES:-} + PI_WEB_UID: ${PI_WEB_UID:-1000} + PI_WEB_GID: ${PI_WEB_GID:-1000} x-pi-web-dev-environment: &pi-web-dev-environment HOME: /data/home diff --git a/docker/compose.yml b/docker/compose.yml index b35a6bd..9e37247 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -8,6 +8,8 @@ x-pi-web-build: &pi-web-build NODEJS_MAJOR: ${PI_WEB_NODEJS_MAJOR:-22} NODEJS_REPO: ${PI_WEB_NODEJS_REPO:-auto} PI_WEB_EXTRA_ZYPPER_PACKAGES: ${PI_WEB_EXTRA_ZYPPER_PACKAGES:-} + PI_WEB_UID: ${PI_WEB_UID:-1000} + PI_WEB_GID: ${PI_WEB_GID:-1000} PI_WEB_VERSION: ${PI_WEB_VERSION:-latest} PI_VERSION: ${PI_VERSION:-latest} CACHE_BUST: ${CACHE_BUST:-local} diff --git a/docker/install.sh b/docker/install.sh index 5e26fda..cfd902c 100755 --- a/docker/install.sh +++ b/docker/install.sh @@ -388,7 +388,7 @@ cat >"$temp_env" < Date: Thu, 25 Jun 2026 19:46:24 +0000 Subject: [PATCH 012/111] fix(docker): include compose and persist home --- .changeset/docker-compose-home.md | 5 +++++ README.md | 21 --------------------- docker/Dockerfile | 1 + docker/Dockerfile.dev | 1 + docker/README.md | 6 ++++-- docker/bin/install-opensuse-base | 10 ++++++---- 6 files changed, 17 insertions(+), 27 deletions(-) create mode 100644 .changeset/docker-compose-home.md diff --git a/.changeset/docker-compose-home.md b/.changeset/docker-compose-home.md new file mode 100644 index 0000000..31d89d7 --- /dev/null +++ b/.changeset/docker-compose-home.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep the Docker setup documented as beta-only, include Docker Compose/Buildx in the container images, and make the container user's home persist under `/data/home`. diff --git a/README.md b/README.md index dba168a..c1b2f30 100644 --- a/README.md +++ b/README.md @@ -72,16 +72,6 @@ Common alternatives: curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/install.sh | sh ``` -For trusted local/server installs, PI WEB also has a Docker local-build runtime: - -```bash -curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh -``` - -The Docker setup builds an openSUSE Tumbleweed based local image from npm, runs split `sessiond` and `web` services, and binds the browser UI to `127.0.0.1:8504` by default. It intentionally mounts the Docker socket and selected host paths; treat it as root-equivalent host access and use an SSH tunnel, VPN, or authenticated reverse proxy for remote access. - -See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, package customization, host command examples, and development Compose usage. - PI WEB is also published as a Pi package: ```bash @@ -183,17 +173,6 @@ pi-web install --dev `dev:web` also watches bundled plugin TypeScript and rebuilds the browser-loaded plugin JavaScript under `dist/pi-web-plugins/`. You can restart `dev:web` or `dev:client` without stopping active Pi sessions. -Docker development from the checkout is available too: - -```bash -export PI_WEB_UID=$(id -u) -export PI_WEB_GID=$(id -g) -export DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) -docker compose -f docker/compose.dev.yml up --build -``` - -Open . The Docker dev setup keeps `sessiond` separate from the autoreloading web/API/client service and uses the runtime Docker data directory by default so sessions can be shared across modes. See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md#development-docker-setup). - For a production-style run from a checkout: ```bash diff --git a/docker/Dockerfile b/docker/Dockerfile index bb2b80a..41a802d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -54,6 +54,7 @@ ENV NODE_ENV=production \ COPY --from=package /usr/local/lib/node_modules /usr/local/lib/node_modules COPY --from=package /usr/local/bin /usr/local/bin COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker +COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins /usr/local/libexec/docker/cli-plugins COPY bin/hostexec /usr/local/bin/hostexec RUN chmod 0755 /usr/local/bin/hostexec diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index f5eda36..a352a2d 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -43,6 +43,7 @@ RUN npm ci \ && chmod 0777 /workspace COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker +COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins /usr/local/libexec/docker/cli-plugins COPY docker/bin/hostexec /usr/local/bin/hostexec RUN chmod 0755 /usr/local/bin/hostexec diff --git a/docker/README.md b/docker/README.md index fd9cd75..22e75d8 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,4 +1,6 @@ -# PI WEB Docker +# PI WEB Docker (beta) + +This Docker setup is beta. It is useful for trusted local/server testing and development, but it may still have rough edges and is intentionally documented only here for now. PI WEB has two Docker modes: @@ -108,7 +110,7 @@ Host-derived IDs are refreshed on rerun unless you explicitly override them. Use ### Base image and tooling -The Docker runtime and development images are openSUSE Tumbleweed based by default. They install Node.js 22, npm, `npx`, and Corepack through zypper, using the openSUSE Node.js build service repository when needed for the selected architecture. The image's `pi-web` account is created with `PI_WEB_UID:PI_WEB_GID`, so shells have a passwd entry instead of showing `I have no name!` when the host user is not `1000:1000`. The image also includes common agent/development tools such as Git/Git LFS, GitHub CLI, OpenSSH, Python with pip/virtualenv and headers, native build tooling, `jq`, `ripgrep`, `fd`, `fzf`, `bat`, ShellCheck, archive tools, network utilities, and the Docker CLI. +The Docker runtime and development images are openSUSE Tumbleweed based by default. They install Node.js 22, npm, `npx`, and Corepack through zypper, using the openSUSE Node.js build service repository when needed for the selected architecture. The image's `pi-web` account is created with `PI_WEB_UID:PI_WEB_GID` and `/data/home` as its home directory, so shells have a passwd entry instead of showing `I have no name!` while user config stays in the persistent `/data` mount. The image also includes common agent/development tools such as Git/Git LFS, GitHub CLI, OpenSSH, Python with pip/virtualenv and headers, native build tooling, `jq`, `ripgrep`, `fd`, `fzf`, `bat`, ShellCheck, archive tools, network utilities, and the Docker CLI with Compose and Buildx plugins. Install extra distro packages without writing a hook by setting a whitespace-delimited package list: diff --git a/docker/bin/install-opensuse-base b/docker/bin/install-opensuse-base index fdfb888..94acd17 100755 --- a/docker/bin/install-opensuse-base +++ b/docker/bin/install-opensuse-base @@ -138,6 +138,9 @@ esac runtime_user=pi-web runtime_group=pi-web +runtime_home=/data/home +mkdir -p "$runtime_home" /data/config /data/npm-cache /data/pi-web /data/pi-agent /workspace + if getent group "$runtime_gid" >/dev/null 2>&1; then runtime_group=$(getent group "$runtime_gid" | cut -d: -f1) elif getent group "$runtime_group" >/dev/null 2>&1; then @@ -147,13 +150,12 @@ else fi if id "$runtime_user" >/dev/null 2>&1; then - usermod --non-unique --uid "$runtime_uid" --gid "$runtime_group" --home "/home/$runtime_user" --shell /bin/bash "$runtime_user" + usermod --non-unique --uid "$runtime_uid" --gid "$runtime_group" --home "$runtime_home" --shell /bin/bash "$runtime_user" else - useradd --non-unique --uid "$runtime_uid" --gid "$runtime_group" --create-home --home-dir "/home/$runtime_user" --shell /bin/bash "$runtime_user" + useradd --non-unique --uid "$runtime_uid" --gid "$runtime_group" --no-create-home --home-dir "$runtime_home" --shell /bin/bash "$runtime_user" fi -mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent /workspace "/home/$runtime_user" -chown -R "$runtime_uid:$runtime_gid" /data /workspace "/home/$runtime_user" +chown -R "$runtime_uid:$runtime_gid" /data /workspace zypper clean --all rm -rf /var/cache/zypp/* From 041423a03be0f4748585a26d9afbb37cd7e8812d Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 26 Jun 2026 18:20:39 +0200 Subject: [PATCH 013/111] chore(release): v1.202606.7 --- .changeset/bidi-chat-text.md | 5 ----- .changeset/chat-file-uploads.md | 5 ----- .changeset/fix-fish-doctor-version-check.md | 9 --------- .changeset/git-inline-diff-highlights.md | 5 ----- .changeset/manual-session-cleanup.md | 5 ----- .changeset/manual-workspace-uploads.md | 5 ----- .changeset/mobile-enter-newline.md | 5 ----- .changeset/persist-subsession-links.md | 5 ----- .changeset/plugin-api-completeness.md | 5 ----- .changeset/plugin-panel-prompt-context.md | 5 ----- CHANGELOG.md | 19 +++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 13 files changed, 22 insertions(+), 57 deletions(-) delete mode 100644 .changeset/bidi-chat-text.md delete mode 100644 .changeset/chat-file-uploads.md delete mode 100644 .changeset/fix-fish-doctor-version-check.md delete mode 100644 .changeset/git-inline-diff-highlights.md delete mode 100644 .changeset/manual-session-cleanup.md delete mode 100644 .changeset/manual-workspace-uploads.md delete mode 100644 .changeset/mobile-enter-newline.md delete mode 100644 .changeset/persist-subsession-links.md delete mode 100644 .changeset/plugin-api-completeness.md delete mode 100644 .changeset/plugin-panel-prompt-context.md diff --git a/.changeset/bidi-chat-text.md b/.changeset/bidi-chat-text.md deleted file mode 100644 index 4b3efb7..0000000 --- a/.changeset/bidi-chat-text.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Improve chat, prompt, and session text rendering for RTL and mixed-direction content. diff --git a/.changeset/chat-file-uploads.md b/.changeset/chat-file-uploads.md deleted file mode 100644 index d65244b..0000000 --- a/.changeset/chat-file-uploads.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Allow chat composer attachments to save and mention general files while preserving native inline image delivery for supported image-only batches. diff --git a/.changeset/fix-fish-doctor-version-check.md b/.changeset/fix-fish-doctor-version-check.md deleted file mode 100644 index 8163c01..0000000 --- a/.changeset/fix-fish-doctor-version-check.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Fix `pi-web doctor` "can find npm/pi" checks on fish. The `--version` check -wrapped the version command in a POSIX subshell `(cmd --version 2>&1 || true)`, -which fish parses as a command substitution in command position and rejects -(`command substitutions not allowed in command position`), producing a false -negative. Emit fish's `begin; ...; end` grouping when the service shell is fish. diff --git a/.changeset/git-inline-diff-highlights.md b/.changeset/git-inline-diff-highlights.md deleted file mode 100644 index 26e3890..0000000 --- a/.changeset/git-inline-diff-highlights.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Highlight within-line changes in the Git diff viewer. diff --git a/.changeset/manual-session-cleanup.md b/.changeset/manual-session-cleanup.md deleted file mode 100644 index 1ae85b1..0000000 --- a/.changeset/manual-session-cleanup.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add a manual sessions cleanup flow that previews and confirms archiving idle sessions and deleting old archived sessions, with per-project selection and capability guidance for unsupported machines. Actions can now expose disabled reasons so unavailable remote-machine actions stay visible with an explanation. diff --git a/.changeset/manual-workspace-uploads.md b/.changeset/manual-workspace-uploads.md deleted file mode 100644 index 033fb63..0000000 --- a/.changeset/manual-workspace-uploads.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add manual Files panel uploads with direct drag/drop, an options flow from the Upload button, safe non-overwrite defaults, visible per-file progress/error reporting with clear failed/cancelled terminal states, and project-local default destinations. diff --git a/.changeset/mobile-enter-newline.md b/.changeset/mobile-enter-newline.md deleted file mode 100644 index f3978be..0000000 --- a/.changeset/mobile-enter-newline.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add a Keyboard shortcuts setting for choosing whether Enter sends chat messages or inserts new lines in this browser, with Shift+Enter performing the opposite action when supported, while preserving the desktop-vs-mobile default (desktop Enter sends; mobile/coarse/narrow Enter inserts a new line). diff --git a/.changeset/persist-subsession-links.md b/.changeset/persist-subsession-links.md deleted file mode 100644 index 8199eaa..0000000 --- a/.changeset/persist-subsession-links.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Persist tracked subsession links in session history so parents can list, check, and read child sessions after the session daemon restarts, and reopened children can resume parent notifications. diff --git a/.changeset/plugin-api-completeness.md b/.changeset/plugin-api-completeness.md deleted file mode 100644 index 5a3b3f3..0000000 --- a/.changeset/plugin-api-completeness.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add workspace file mutation (`files.writeFile`, `files.deleteFile`, `files.moveFile`) and prompt editor (`prompt.insertText`, `prompt.getText`, `prompt.getSelection`) APIs to the plugin system. File mutations work for local and federated machines, enforce workspace path safety, and auto-refresh the File Explorer. diff --git a/.changeset/plugin-panel-prompt-context.md b/.changeset/plugin-panel-prompt-context.md deleted file mode 100644 index e7ee9c2..0000000 --- a/.changeset/plugin-panel-prompt-context.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Expose the plugin prompt editor helper in workspace panel contexts so panel interactions can insert text into the current prompt. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7617ac9..6393e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # @jmfederico/pi-web +## 1.202606.7 + +### Patch Changes + +- b17faeb: Improve chat, prompt, and session text rendering for RTL and mixed-direction content. +- 7e812aa: Allow chat composer attachments to save and mention general files while preserving native inline image delivery for supported image-only batches. +- 47c9b66: Fix `pi-web doctor` "can find npm/pi" checks on fish. The `--version` check + wrapped the version command in a POSIX subshell `(cmd --version 2>&1 || true)`, + which fish parses as a command substitution in command position and rejects + (`command substitutions not allowed in command position`), producing a false + negative. Emit fish's `begin; ...; end` grouping when the service shell is fish. +- b14205e: Highlight within-line changes in the Git diff viewer. +- cb13af4: Add a manual sessions cleanup flow that previews and confirms archiving idle sessions and deleting old archived sessions, with per-project selection and capability guidance for unsupported machines. Actions can now expose disabled reasons so unavailable remote-machine actions stay visible with an explanation. +- e46d9ec: Add manual Files panel uploads with direct drag/drop, an options flow from the Upload button, safe non-overwrite defaults, visible per-file progress/error reporting with clear failed/cancelled terminal states, and project-local default destinations. +- 32ea809: Add a Keyboard shortcuts setting for choosing whether Enter sends chat messages or inserts new lines in this browser, with Shift+Enter performing the opposite action when supported, while preserving the desktop-vs-mobile default (desktop Enter sends; mobile/coarse/narrow Enter inserts a new line). +- a99696b: Persist tracked subsession links in session history so parents can list, check, and read child sessions after the session daemon restarts, and reopened children can resume parent notifications. +- 27a3b2b: Add workspace file mutation (`files.writeFile`, `files.deleteFile`, `files.moveFile`) and prompt editor (`prompt.insertText`, `prompt.getText`, `prompt.getSelection`) APIs to the plugin system. File mutations work for local and federated machines, enforce workspace path safety, and auto-refresh the File Explorer. +- 9980027: Expose the plugin prompt editor helper in workspace panel contexts so panel interactions can insert text into the current prompt. + ## 1.202606.6 ### Patch Changes diff --git a/package-lock.json b/package-lock.json index e0d243f..4fb83fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jmfederico/pi-web", - "version": "1.202606.6", + "version": "1.202606.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jmfederico/pi-web", - "version": "1.202606.6", + "version": "1.202606.7", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.10.3", diff --git a/package.json b/package.json index ee6caef..3ca31a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jmfederico/pi-web", - "version": "1.202606.6", + "version": "1.202606.7", "description": "Web UI for persistent Pi Coding Agent sessions in real workspaces.", "license": "MIT", "author": "Federico Jaramillo Martinez", From 2009e6a8838ed60502083f4f0da90926073a1b81 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 27 Jun 2026 08:12:01 +0200 Subject: [PATCH 014/111] fix: keep chat prompt input stable during streaming Coalesce session status/activity updates into one render per animation frame instead of one per token, ignore prompt-editor status changes that do not affect what it displays, and stop per-keystroke draft state from re-rendering the surrounding template. This prevents streaming-driven re-renders from interrupting in-progress touch gestures such as the iOS long-press paste/edit callout. --- .../prompt-editor-stable-during-streaming.md | 5 + src/client/src/components/PiWebApp.ts | 21 ++- src/client/src/components/PromptEditor.ts | 50 ++++++- .../src/controllers/sessionController.test.ts | 133 +++++++++++++++++- .../src/controllers/sessionController.ts | 99 +++++++++---- src/client/src/inputModes.test.ts | 9 +- src/client/src/inputModes.ts | 6 + 7 files changed, 287 insertions(+), 36 deletions(-) create mode 100644 .changeset/prompt-editor-stable-during-streaming.md diff --git a/.changeset/prompt-editor-stable-during-streaming.md b/.changeset/prompt-editor-stable-during-streaming.md new file mode 100644 index 0000000..a4bc709 --- /dev/null +++ b/.changeset/prompt-editor-stable-during-streaming.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep the chat prompt input stable during streaming so mobile touch gestures (such as the iOS long-press paste/edit callout) are no longer interrupted. Session status and activity updates are now coalesced into a single render per animation frame instead of one per token, the prompt editor ignores status changes that do not affect what it displays, and per-keystroke draft state no longer triggers surrounding re-renders. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index ba6ae20..30cc18d 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1830,6 +1830,25 @@ export class PiWebApp extends LitElement { void this.sessions.send(text, streamingBehavior, attachments, delivery); } + // Stable handler identities for . Inlined arrow closures would + // be a fresh reference on every render, forcing Lit to re-commit the bindings + // each time the app re-renders; bound class fields keep them constant. + private readonly handleSendPrompt = (text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery): void => { + this.sendPrompt(text, streamingBehavior, attachments, delivery); + }; + + private readonly handleStopActiveWork = (): void => { + void this.sessions.stopActiveWork(); + }; + + private readonly handleSelectModel = (): void => { + void this.openModelDialog(); + }; + + private readonly handleSelectThinking = (): void => { + void this.openThinkingDialog(); + }; + private renderContextBar() { if (!this.appShell.isMobileNavigationLayout) return null; return html` @@ -1889,7 +1908,7 @@ export class PiWebApp extends LitElement {
${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}
${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> - 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> + 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${this.handleSendPrompt} .onStop=${this.handleStopActiveWork} .onSelectModel=${this.handleSelectModel} .onSelectThinking=${this.handleSelectThinking}> ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ${state.modelDialog !== undefined ? html` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 0fcffaf..7c79bdc 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -8,7 +8,7 @@ import { customElement, property, query, state } from "lit/decorators.js"; import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api"; import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture"; -import { inputModeForDraft } from "../inputModes"; +import { inputModeForDraft, inputModesEqual, type InputMode } from "../inputModes"; import { machineSessionKey } from "../machineKeys"; import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; @@ -42,7 +42,14 @@ export class PromptEditor extends LitElement { @property({ attribute: false }) availableThinkingLevels: readonly string[] = []; @query(".markdown-editor") private editorHost?: HTMLDivElement; @query(".attachment-input") private attachmentInput?: HTMLInputElement; - @state() private draft = ""; + // `draft` is the live document text but is intentionally NOT reactive: it + // changes on every keystroke and the visible text is owned by CodeMirror, not + // by Lit's render. Re-rendering the surrounding template on each keystroke is + // wasted work and, on iOS, can interrupt an in-progress touch gesture (the + // long-press edit/paste callout). Only `currentInputMode` (shell vs. normal) + // is reactive, since that is the only draft-derived value the template shows. + private draft = ""; + @state() private currentInputMode: InputMode = { kind: "normal" }; @state() private completions: CompletionItem[] = []; @state() private selectedIndex = 0; @state() private attachments: PendingAttachment[] = []; @@ -64,17 +71,29 @@ export class PromptEditor extends LitElement { if (previousKey !== undefined) saveDraft(previousKey, this.draft); const currentKey = draftStorageKey(this.machineId, this.sessionId); this.draft = currentKey !== undefined ? loadDraft(currentKey) : ""; + this.currentInputMode = inputModeForDraft(this.draft); this.completions = []; this.selectedIndex = 0; } + protected override shouldUpdate(changed: PropertyValues): boolean { + // Status updates churn once per token during streaming and hand us a fresh + // object reference each time. When nothing else changed, only re-render if a + // status field the template actually displays differs, so streaming does not + // disturb the editor DOM (and any in-progress touch gesture survives). + if (changed.has("status") && changed.size === 1) { + return !sessionStatusRenderEqual(changed.get("status"), this.status); + } + return true; + } + override firstUpdated(): void { this.createEditor(); } protected override updated(changed: PropertyValues) { if (changed.has("disabled")) this.updateEditorDisabledState(); - if (changed.has("draft") || changed.has("sessionId") || changed.has("machineId")) this.syncEditorDoc(); + if (changed.has("sessionId") || changed.has("machineId")) this.syncEditorDoc(); } override disconnectedCallback(): void { @@ -84,8 +103,8 @@ export class PromptEditor extends LitElement { } override render() { - const inputMode = inputModeForDraft(this.draft); - const shellMode = inputMode.kind === "shell"; + const shellInputMode = this.currentInputMode.kind === "shell" ? this.currentInputMode : undefined; + const shellMode = shellInputMode !== undefined; const queuesInput = this.canSteer || this.isCompacting; const busy = this.disabled || this.sending; return html` @@ -94,7 +113,7 @@ export class PromptEditor extends LitElement {
{ void this.handleFileInput(event); }} /> - ${shellMode ? html`
Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}
` : null} + ${shellMode ? html`
Shell command${shellInputMode.excludeFromContext ? " · excluded from context" : ""}
` : null} ${this.isCompacting && !shellMode ? html`
Compacting history · message will be queued
` : null} ${this.renderAttachments()} { this.pick(item); }}> @@ -288,6 +307,8 @@ export class PromptEditor extends LitElement { this.draft = value; const key = draftStorageKey(this.machineId, this.sessionId); if (key !== undefined) saveDraft(key, this.draft); + const nextInputMode = inputModeForDraft(this.draft); + if (!inputModesEqual(nextInputMode, this.currentInputMode)) this.currentInputMode = nextInputMode; void this.refreshCompletions(); } @@ -432,16 +453,33 @@ export class PromptEditor extends LitElement { private resetComposer() { this.draft = ""; + this.currentInputMode = { kind: "normal" }; const key = draftStorageKey(this.machineId, this.sessionId); if (key !== undefined) clearDraft(key); this.completions = []; this.attachments = []; this.attachmentError = undefined; + // `draft` is not reactive, so the cleared text will not flow to CodeMirror + // via `updated()`; push it to the editor document explicitly. + this.syncEditorDoc(); } static override styles = promptEditorStyles; } +// The only `status` fields the template reads directly are the model identity +// and thinking level (shown in renderCompactStatus). Everything else the editor +// cares about (canSteer/canStop/isCompacting/sending) is passed as a separate +// property that Lit already diffs by value. Comparing just these fields lets us +// ignore the per-token status churn that does not change anything on screen. +function sessionStatusRenderEqual(a: SessionStatus | undefined, b: SessionStatus | undefined): boolean { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + return a.model?.id === b.model?.id + && a.model?.provider === b.model?.provider + && a.thinkingLevel === b.thinkingLevel; +} + function draftStorageKey(machineId: unknown, sessionId: unknown): string | undefined { if (typeof machineId !== "string" || machineId === "") return undefined; if (typeof sessionId !== "string" || sessionId === "") return undefined; diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 261c36e..a58f6c5 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { api as defaultApi, type MessagePage, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type Workspace } from "../api"; +import type { SessionUiEvent } from "../sessionSocket"; import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions"; import { initialAppState, type AppState } from "../appState"; import { machineSessionKey } from "../machineKeys"; @@ -52,6 +53,28 @@ class FakeSocket implements SessionEventSocket { } } +class EmitSocket implements SessionEventSocket { + readonly connectedSessionIds: string[] = []; + private handler: ((event: SessionUiEvent) => void) | undefined; + + connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void): void { + this.connectedSessionIds.push(session.id); + this.handler = onEvent; + } + + setHandler(onEvent: (event: SessionUiEvent) => void): void { + this.handler = onEvent; + } + + emit(event: SessionUiEvent): void { + this.handler?.(event); + } + + close(): void { + this.handler = undefined; + } +} + const workspace: Workspace = { id: "workspace-1", projectId: "project-1", @@ -93,11 +116,116 @@ function status(sessionId: string): SessionStatus { }; } +const framesById = new Map void>(); +let nextFrameId = 1; + +// The controller coalesces status/activity/transcript updates behind +// requestAnimationFrame. The node test environment has no rAF, so install a +// controllable one: callbacks are queued and only run when a test drives a +// frame, mirroring how the browser defers them until paint. +beforeEach(() => { + framesById.clear(); + nextFrameId = 1; + vi.stubGlobal("requestAnimationFrame", (callback: () => void) => { + const id = nextFrameId++; + framesById.set(id, callback); + return id; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => { framesById.delete(id); }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function runPendingAnimationFrames(): void { + const frames = Array.from(framesById.values()); + framesById.clear(); + for (const frame of frames) frame(); +} + describe("SessionController", () => { afterEach(() => { Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true }); }); + it("coalesces rapid status updates into a single state write per frame", () => { + const setStateCalls: Partial[] = []; + let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] }; + const controller = new SessionController( + () => state, + (patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; }, + () => undefined, + undefined, + { socket: new FakeSocket() }, + ); + + controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 1 } }); + controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 2 } }); + controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 3 } }); + + // Nothing applies until the frame is flushed; last-write-wins per session. + expect(setStateCalls).toHaveLength(0); + expect(state.sessionStatuses[oldSession.id]).toBeUndefined(); + + runPendingAnimationFrames(); + + expect(setStateCalls).toHaveLength(1); + expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, messageCount: 3 }); + expect(state.status?.messageCount).toBe(3); + }); + + it("applies the latest activity per session on flush", () => { + const setStateCalls: Partial[] = []; + let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] }; + const controller = new SessionController( + () => state, + (patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; }, + () => undefined, + undefined, + { socket: new FakeSocket() }, + ); + + controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "active", label: "running tool", at: "t1" } }); + controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "idle", label: "idle", at: "t2" } }); + + expect(setStateCalls).toHaveLength(0); + + controller.flushPendingUpdates(); + + expect(state.sessionActivities[oldSession.id]).toMatchObject({ phase: "idle", label: "idle" }); + expect(state.activity?.phase).toBe("idle"); + }); + + it("coalesces status updates delivered over the per-session socket until the frame is flushed", async () => { + const socket = new EmitSocket(); + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] }; + const api: typeof defaultApi = { + ...defaultApi, + messages: () => Promise.resolve(emptyPage), + status: () => Promise.resolve(status(oldSession.id)), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket }, + ); + await controller.selectSession(oldSession, { updateUrl: false }); + + socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 7 } }); + socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 8 } }); + + // Buffered, not applied synchronously. + expect(state.sessionStatuses[oldSession.id]?.messageCount).toBeUndefined(); + + controller.flushPendingUpdates(); + + expect(state.sessionStatuses[oldSession.id]?.messageCount).toBe(8); + expect(state.status?.messageCount).toBe(8); + }); + it("clears stale active activity when an idle status arrives", () => { const activeActivity: SessionActivity = { sessionId: oldSession.id, phase: "active", label: "running tool", at: "2026-05-15T00:00:00.000Z" }; let state: AppState = { @@ -116,6 +244,7 @@ describe("SessionController", () => { ); controller.applyGlobalEvent({ type: "status.update", status: status(oldSession.id) }); + controller.flushPendingUpdates(); expect(state.activity).toBeUndefined(); expect(state.sessionActivities[oldSession.id]).toBeUndefined(); @@ -137,6 +266,7 @@ describe("SessionController", () => { ); controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 3 } }); + controller.flushPendingUpdates(); expect(state.sessions[0]?.messageCount).toBe(3); expect(state.selectedSession?.messageCount).toBe(3); @@ -356,6 +486,7 @@ describe("SessionController", () => { const send = controller.send("hello"); controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 1 } }); + controller.flushPendingUpdates(); resolvePrompt?.(); await send; diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index b3576a3..a8b5976 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -34,7 +34,9 @@ export class SessionController { private selectionSeq = 0; private catchupStreamSessionId: string | undefined; private pendingTranscriptEvents: SessionUiEvent[] = []; - private pendingTranscriptFrame: number | undefined; + private pendingStatusBySession = new Map(); + private pendingActivityBySession = new Map(); + private pendingFrame: number | undefined; constructor( private readonly getState: GetState, @@ -49,22 +51,22 @@ export class SessionController { } applyGlobalEvent(event: GlobalSessionEvent): void { - if (event.type === "status.update") this.applyStatus(event.status); - else if (event.type === "activity.update") this.applyActivity(event.activity); + if (event.type === "status.update") this.queueStatusUpdate(event.status); + else if (event.type === "activity.update") this.queueActivityUpdate(event.activity); else if (event.type === "session.created") this.applyCreatedSession(event.session); else this.applySessionName(event.sessionId, event.name); } dispose() { this.socket.close(); - this.clearPendingTranscriptEvents(); + this.clearPendingUpdates(); } clearActiveSession() { this.selectionSeq += 1; this.socket.close(); this.catchupStreamSessionId = undefined; - this.clearPendingTranscriptEvents(); + this.clearPendingUpdates(); // Note: sendingPrompts is intentionally NOT cleared here. Deselecting a // session must not cancel the in-flight upload indicator of the session // that is still sending; the per-session entry is cleared by send()'s @@ -113,7 +115,7 @@ export class SessionController { const seq = ++this.selectionSeq; this.socket.close(); this.catchupStreamSessionId = undefined; - this.clearPendingTranscriptEvents(); + this.clearPendingUpdates(); const transcriptKey = this.sessionCacheKey(session.id); const cached = this.transcripts.cachedView(transcriptKey); this.setState({ @@ -563,7 +565,7 @@ export class SessionController { const session = this.getState().selectedSession; if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return; try { - this.flushPendingTranscriptEvents(); + this.flushPendingUpdates(); const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]); if (this.getState().selectedSession?.id !== sessionId) return; const history = this.transcripts.mergeHistory(this.sessionCacheKey(sessionId), page); @@ -694,19 +696,29 @@ export class SessionController { if (isTranscriptEvent(event)) return; } + // Status and activity arrive once per token (the server republishes them on + // every transcript event). Buffer them alongside high-frequency transcript + // deltas so the host component renders at most once per animation frame + // instead of once per token. Coalescing these here is what keeps the prompt + // editor's DOM stable during streaming, so in-progress touch gestures (e.g. + // the iOS long-press edit/paste callout) are not interrupted by a re-render. + if (event.type === "status.update") { + this.queueStatusUpdate(event.status); + return; + } + if (event.type === "activity.update") { + this.queueActivityUpdate(event.activity); + return; + } if (isHighFrequencyTranscriptEvent(event)) { this.queueTranscriptEvent(event); return; } - this.flushPendingTranscriptEvents(); + this.flushPendingUpdates(); const transcript = this.transcripts.applyLiveEvent(this.getState().messages, event); if (transcript) { this.setState({ messages: transcript }); - } else if (event.type === "status.update") { - this.applyStatus(event.status); - } else if (event.type === "activity.update") { - this.applyActivity(event.activity); } else if (event.type === "session.name") { this.applySessionName(event.sessionId, event.name); } @@ -714,27 +726,60 @@ export class SessionController { private queueTranscriptEvent(event: SessionUiEvent): void { this.pendingTranscriptEvents.push(event); - if (this.pendingTranscriptFrame !== undefined) return; - this.pendingTranscriptFrame = requestAnimationFrame(() => { - this.pendingTranscriptFrame = undefined; - this.flushPendingTranscriptEvents(); + this.schedulePendingFlush(); + } + + private queueStatusUpdate(status: SessionStatus): void { + this.pendingStatusBySession.set(status.sessionId, status); + this.schedulePendingFlush(); + } + + private queueActivityUpdate(activity: SessionActivity): void { + this.pendingActivityBySession.set(activity.sessionId, activity); + this.schedulePendingFlush(); + } + + private schedulePendingFlush(): void { + if (this.pendingFrame !== undefined) return; + this.pendingFrame = requestAnimationFrame(() => { + this.pendingFrame = undefined; + this.flushPendingUpdates(); }); } - private flushPendingTranscriptEvents(): void { - if (this.pendingTranscriptEvents.length === 0) return; - const events = this.pendingTranscriptEvents; - this.pendingTranscriptEvents = []; - let messages = this.getState().messages; - for (const event of events) messages = this.transcripts.applyLiveEvent(messages, event) ?? messages; - if (messages !== this.getState().messages) this.setState({ messages }); + // Apply buffered transcript deltas, activity, and status in one task. Activity + // is applied before status to mirror the server's publish order, so an idle + // status can clear the now-stale active activity it supersedes. Status and + // activity are last-write-wins per session, so iterating the maps applies only + // the latest buffered value per session. These writes run in a single task, so + // Lit batches them into one render. + flushPendingUpdates(): void { + if (this.pendingTranscriptEvents.length > 0) { + const events = this.pendingTranscriptEvents; + this.pendingTranscriptEvents = []; + let messages = this.getState().messages; + for (const event of events) messages = this.transcripts.applyLiveEvent(messages, event) ?? messages; + if (messages !== this.getState().messages) this.setState({ messages }); + } + if (this.pendingActivityBySession.size > 0) { + const activities = Array.from(this.pendingActivityBySession.values()); + this.pendingActivityBySession.clear(); + for (const activity of activities) this.applyActivity(activity); + } + if (this.pendingStatusBySession.size > 0) { + const statuses = Array.from(this.pendingStatusBySession.values()); + this.pendingStatusBySession.clear(); + for (const status of statuses) this.applyStatus(status); + } } - private clearPendingTranscriptEvents(): void { + private clearPendingUpdates(): void { this.pendingTranscriptEvents = []; - if (this.pendingTranscriptFrame === undefined) return; - cancelAnimationFrame(this.pendingTranscriptFrame); - this.pendingTranscriptFrame = undefined; + this.pendingStatusBySession.clear(); + this.pendingActivityBySession.clear(); + if (this.pendingFrame === undefined) return; + cancelAnimationFrame(this.pendingFrame); + this.pendingFrame = undefined; } // Stream catch-up is a single mode with two coupled facets that must never diff --git a/src/client/src/inputModes.test.ts b/src/client/src/inputModes.test.ts index 5f14055..f4e8ea3 100644 --- a/src/client/src/inputModes.test.ts +++ b/src/client/src/inputModes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { inputModeForDraft, isShellInput } from "./inputModes"; +import { inputModeForDraft, inputModesEqual, isShellInput } from "./inputModes"; describe("inputModeForDraft", () => { it("detects shell input and context-excluded shell input after leading whitespace", () => { @@ -14,6 +14,13 @@ describe("inputModeForDraft", () => { expect(inputModeForDraft("please mention/path")).toEqual({ kind: "normal" }); }); + it("treats modes as equal only when kind and shell context-exclusion match", () => { + expect(inputModesEqual({ kind: "normal" }, { kind: "normal" })).toBe(true); + expect(inputModesEqual({ kind: "normal" }, { kind: "command" })).toBe(false); + expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: false })).toBe(true); + expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: true })).toBe(false); + }); + it("detects file completion contexts", () => { expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" }); expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" }); diff --git a/src/client/src/inputModes.ts b/src/client/src/inputModes.ts index ed59118..bfeb755 100644 --- a/src/client/src/inputModes.ts +++ b/src/client/src/inputModes.ts @@ -19,6 +19,12 @@ export function isShellInput(text: string): boolean { return inputModeForDraft(text).kind === "shell"; } +export function inputModesEqual(a: InputMode, b: InputMode): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === "shell" && b.kind === "shell") return a.excludeFromContext === b.excludeFromContext; + return true; +} + function currentToken(draft: string): string { const tokenStart = Math.max(draft.lastIndexOf(" "), draft.lastIndexOf("\n")) + 1; return draft.slice(tokenStart); From 2987b6f282bae068cea9466a1ede4af0eb7e8d6b Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Sat, 27 Jun 2026 23:05:40 +0000 Subject: [PATCH 015/111] feat(docker): add fail-closed host profiles --- .changeset/docker-host-profiles.md | 5 + docker/README.md | 102 ++++++--- docker/bin/hostexec | 14 ++ docker/compose.dev.yml | 17 +- docker/compose.yml | 17 +- docker/install.sh | 100 +++++--- docker/lib/host-profile.sh | 355 +++++++++++++++++++++++++++++ docker/scripts/docker-compose-dev | 236 +++++++++++++++++++ 8 files changed, 745 insertions(+), 101 deletions(-) create mode 100644 .changeset/docker-host-profiles.md create mode 100644 docker/lib/host-profile.sh create mode 100755 docker/scripts/docker-compose-dev diff --git a/.changeset/docker-host-profiles.md b/.changeset/docker-host-profiles.md new file mode 100644 index 0000000..4137426 --- /dev/null +++ b/.changeset/docker-host-profiles.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add fail-closed Docker host profile detection with Linux and Docker Desktop for Mac Compose overrides, and split Docker dev configuration into a user-editable local env file plus generated Compose inputs. diff --git a/docker/README.md b/docker/README.md index 22e75d8..6e7c5ac 100644 --- a/docker/README.md +++ b/docker/README.md @@ -13,12 +13,11 @@ No prebuilt image or registry is required in either mode. The Docker setup is for trusted single-user or trusted-admin environments. It is not a sandbox and it is not suitable for untrusted multi-tenant use. -By design, the runtime containers get deliberate host access so PI WEB agents can work on real server paths: +By design, the runtime containers get deliberate host access so PI WEB agents can work on real host paths: -- `/var/run/docker.sock` is mounted into the containers. The Docker socket is root-equivalent on the host. -- `/srv`, `/opt`, and `/home` are mounted read/write. -- `/` is mounted read-only at `/host` for inspection. -- `hostexec` can start a temporary privileged helper container and run explicit commands in the host namespaces. Commands run as the container user by default, and `hostexec --root` can still run explicit administrative commands as root. +- `/var/run/docker.sock` is mounted into the containers. The Docker socket is root-equivalent on the Docker host. +- On native Linux Docker Engine, existing `/home`, `/srv`, and `/opt` paths are mounted read/write, `/` is mounted read-only at `/host` for inspection, and `hostexec` can run explicit commands in the Linux host namespaces. +- On Docker Desktop for Mac, existing `/Users`, `/Volumes`, and `/private` paths are mounted read/write. `hostexec` is disabled because Docker Desktop containers run inside a Linux VM and cannot enter native macOS namespaces. Only install this on machines where the PI WEB user, the selected workspaces, and the browser/API clients are trusted. Review scripts before piping them to `sh` if you do not already trust this repository. @@ -32,10 +31,15 @@ The web port is bound to `127.0.0.1` by default. Do **not** expose PI WEB direct Prerequisites: -- Docker Engine with the Compose plugin (`docker compose`) or `docker-compose`; +- one supported Docker host profile: + - native Linux Docker Engine using the local `/var/run/docker.sock`; or + - Docker Desktop for Mac; +- Docker Compose through the `docker compose` plugin or `docker-compose`; - a user that can talk to the Docker daemon; - `curl` or `wget` for the one-liner installer. +The installer fails closed on unknown or unsupported Docker setups, such as remote Docker contexts, `DOCKER_HOST` overrides outside the supported local Unix socket, rootless/alternate Linux sockets, Docker Desktop for Linux, Colima, or OrbStack. It prints the detected host OS, Docker context, endpoint, `DOCKER_HOST`, socket source, and Docker OS before exiting, and it does not recreate services. + Install or update with the same command: ```bash @@ -96,6 +100,8 @@ Common environment variables written to `.env`: | `PI_WEB_UID`, `PI_WEB_GID` | user/group used by the runtime containers and the image's `pi-web` account | | `DOCKER_GID` | extra group used for Docker socket access | | `PI_WEB_DOCKER_DATA_DIR` | persistent data bind mount | +| `PI_WEB_DOCKER_HOST_PROFILE`, `HOSTEXEC_MODE` | detected host profile and host-command capability toggle | +| `PI_WEB_DOCKER_EXTRA_HOST_PATHS` | optional whitespace-separated existing absolute paths to bind-mount read/write at the same path | | `PI_WEB_BIND_ADDR`, `PI_WEB_PORT` | host bind address and port | | `PI_WEB_VERSION` | npm version/range for `@jmfederico/pi-web` | | `PI_VERSION` | npm version/range for `@earendil-works/pi-coding-agent` | @@ -106,7 +112,9 @@ Common environment variables written to `.env`: | `PI_WEB_IMAGE` | local image tag to build and run | | `HOSTEXEC_IMAGE` | helper image used by `hostexec` | -Host-derived IDs are refreshed on rerun unless you explicitly override them. User-facing values such as data directory, bind address, port, image names, upload limit, base image, Node.js settings, extra packages, and version pins are preserved from an existing `.env` unless you pass a flag or environment override. +Host-derived IDs and the Docker host profile are refreshed on rerun unless you explicitly override the IDs. User-facing values such as data directory, bind address, port, image names, upload limit, extra host paths, base image, Node.js settings, extra packages, and version pins are preserved from an existing `.env` unless you pass a flag or environment override. + +The installer also writes a generated `compose.override.yml` in the install directory. Docker Compose loads it automatically for ordinary `docker compose ...` commands run from that directory; re-run the installer instead of editing that generated file by hand. ### Base image and tooling @@ -204,9 +212,11 @@ If you use a reverse proxy, keep the container bound to localhost or a private a ## `hostexec` examples -`hostexec [--root] ` is the only host command bridge provided by this Docker setup. It intentionally does not abstract package managers or detect distributions. By default, commands run as the same numeric user/group as the PI WEB container. Use `--root` only for administrative host commands. +`hostexec [--root] ` is the native Linux host command bridge provided by this Docker setup. It is enabled only for the `linux-native-docker` profile and intentionally does not abstract package managers or detect distributions. By default, commands run as the same numeric user/group as the PI WEB container. Use `--root` only for administrative host commands. -Run it from a PI WEB session, a PI WEB terminal, or by execing into the runtime container: +On Docker Desktop for Mac, `hostexec` exits with a clear disabled message because the Docker daemon and containers run inside a Linux VM, not in native macOS namespaces. Docker CLI and Docker Compose commands still work through the mounted Docker socket. + +Run it from a PI WEB session, a PI WEB terminal, or by execing into the runtime container on native Linux: ```bash hostexec uname -a @@ -223,7 +233,7 @@ cd ~/.local/share/pi-web-docker docker compose exec web hostexec uname -a ``` -`hostexec` starts a temporary privileged helper container through the mounted Docker socket, enters the host namespaces with `nsenter`, and runs exactly the command you passed. Treat it like privileged host access even when the final command drops back to the container user. +On native Linux, `hostexec` starts a temporary privileged helper container through the mounted Docker socket, enters the host namespaces with `nsenter`, and runs exactly the command you passed. Treat it like privileged host access even when the final command drops back to the container user. ## Development Docker setup @@ -232,24 +242,42 @@ Use this mode when developing PI WEB from this checkout. It bind-mounts the sour - `sessiond` runs `npm run start:sessiond` as the long-lived owner of Pi agent runtimes; - `web` runs `npm run dev:web` and `npm run dev:client` so API, plugin, and Vite changes can autoreload without restarting `sessiond`. -From the repository root: +From the repository root, use the dev Compose wrapper so the same fail-closed host profile detection is applied as runtime mode: ```bash -export PI_WEB_UID=$(id -u) -export PI_WEB_GID=$(id -g) -export DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) -# Optional; this is also the default dev data path. -export PI_WEB_DOCKER_DATA_DIR=${PI_WEB_DOCKER_DATA_DIR:-$HOME/.local/share/pi-web-docker/data} -mkdir -p "$PI_WEB_DOCKER_DATA_DIR" - -docker compose -f docker/compose.dev.yml up --build +./docker/scripts/docker-compose-dev up --build ``` -If you already ran the runtime installer, you can reuse its `.env` so dev mode gets the same UID/GID, Docker group, ports, and data directory: +The wrapper creates `.pi-web/docker-compose-dev.local.env` on first run, writes `.pi-web/docker-compose-dev.generated.env` and `.pi-web/docker-compose-dev.host.generated.yml`, then runs Docker Compose with `docker/compose.dev.yml` plus that generated host override. Edit only the `.local.env` file for persistent dev settings; the `.generated.env` and `.host.generated.yml` files are refreshed by the wrapper. + +Values used by the wrapper are resolved in this order: + +1. current shell environment, for this run only; +2. `.pi-web/docker-compose-dev.local.env`; +3. runtime installer env, usually `$HOME/.local/share/pi-web-docker/.env`; +4. built-in defaults. + +If you already ran the runtime installer, dev mode therefore reuses defaults such as UID/GID, Docker group, data directory, extra host paths, image build inputs, upload limit, and bind address unless you set a more specific value in the shell or `.local.env`. If an older `.pi-web/docker-compose-dev.env` exists, the first run copies its dev bind/port values into `.local.env` so previous local exposure settings are easy to see and edit. + +To expose the dev API and Vite UI beyond localhost persistently, edit `.pi-web/docker-compose-dev.local.env`: + +```dotenv +PI_WEB_DEV_API_BIND_ADDR=0.0.0.0 +PI_WEB_DEV_BIND_ADDR=0.0.0.0 +``` + +For temporary overrides, prefix the wrapper command: ```bash -docker compose --env-file "$HOME/.local/share/pi-web-docker/.env" \ - -f docker/compose.dev.yml up --build +PI_WEB_DEV_API_BIND_ADDR=0.0.0.0 \ +PI_WEB_DEV_BIND_ADDR=0.0.0.0 \ + ./docker/scripts/docker-compose-dev up -d --build +``` + +You can run the dev stack in the background with: + +```bash +./docker/scripts/docker-compose-dev up -d --build ``` Open the Vite UI at . The dev API is published on . @@ -257,16 +285,16 @@ Open the Vite UI at . The dev API is published on `, `/srv/`, or `/opt/`. The dev container also exposes this checkout as `/workspace` so the PI WEB dev server can run from it, but sessions started against `/workspace` are organized under that different working-directory path and will not line up with runtime sessions for `/home/core/`. +For sessions to appear under the same workspace in both modes, use the same project path in PI WEB. On Linux, prefer host-mounted paths such as `/home/core/`, `/srv/`, or `/opt/`. On Mac, prefer paths under `/Users//...`. The dev container also exposes this checkout as `/workspace` so the PI WEB dev server can run from it, but sessions started against `/workspace` are organized under that different working-directory path and will not line up with runtime sessions for the host-mounted path. When `package-lock.json` changes, rebuild the dev image and recreate the `node_modules` volume so the bind-mounted checkout sees the new dependency tree: ```bash -docker compose -f docker/compose.dev.yml down +./docker/scripts/docker-compose-dev down docker volume rm pi-web-dev_node_modules -docker compose -f docker/compose.dev.yml up --build +./docker/scripts/docker-compose-dev up --build ``` ## Local checkout validation @@ -309,11 +337,17 @@ PI_WEB_DOCKER_HOME="$(mktemp -d)" \ sh docker/install.sh ``` -For Compose validation: +For Compose validation after generating host overrides: ```bash -docker compose -f docker/compose.yml config -docker compose -f docker/compose.dev.yml config +tmp_home=$(mktemp -d) +PI_WEB_DOCKER_SKIP_COMPOSE=1 \ +PI_WEB_DOCKER_ASSET_DIR="$PWD/docker" \ +PI_WEB_DOCKER_HOME="$tmp_home" \ +sh docker/install.sh + +docker compose -f "$tmp_home/compose.yml" -f "$tmp_home/compose.override.yml" config +./docker/scripts/docker-compose-dev config docker build --check -f docker/Dockerfile docker docker build --check -f docker/Dockerfile.dev . ``` diff --git a/docker/bin/hostexec b/docker/bin/hostexec index c46a3d0..182cba0 100755 --- a/docker/bin/hostexec +++ b/docker/bin/hostexec @@ -38,6 +38,20 @@ if [ "$#" -eq 0 ]; then exit 64 fi +hostexec_mode="${HOSTEXEC_MODE:-nsenter}" +case "$hostexec_mode" in + nsenter) ;; + disabled|none) + echo "hostexec: disabled for this Docker host profile" >&2 + echo "hostexec: on Docker Desktop for Mac, containers run inside a Linux VM and cannot enter native macOS namespaces" >&2 + exit 69 + ;; + *) + echo "hostexec: unsupported HOSTEXEC_MODE: $hostexec_mode" >&2 + exit 64 + ;; +esac + if ! command -v docker >/dev/null 2>&1; then echo "hostexec: docker CLI not found in this container" >&2 exit 127 diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index fec1abc..4ffb009 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -18,6 +18,7 @@ x-pi-web-dev-environment: &pi-web-dev-environment PI_WEB_SESSIOND_SOCKET: /data/pi-web/sessiond.sock PI_CODING_AGENT_DIR: /data/pi-agent HOSTEXEC_IMAGE: ${HOSTEXEC_IMAGE:-alpine:3.22} + HOSTEXEC_MODE: ${HOSTEXEC_MODE:-disabled} PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864} NPM_CONFIG_UPDATE_NOTIFIER: "false" NPM_CONFIG_CACHE: /data/npm-cache @@ -35,22 +36,6 @@ x-pi-web-dev-volumes: &pi-web-dev-volumes source: node_modules target: /workspace/node_modules - *pi-web-dev-data-volume - - type: bind - source: /var/run/docker.sock - target: /var/run/docker.sock - - type: bind - source: /srv - target: /srv - - type: bind - source: /opt - target: /opt - - type: bind - source: /home - target: /home - - type: bind - source: / - target: /host - read_only: true services: data-init: diff --git a/docker/compose.yml b/docker/compose.yml index 9e37247..0ed4d45 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -21,28 +21,13 @@ x-pi-web-environment: &pi-web-environment PI_WEB_SESSIOND_SOCKET: /data/pi-web/sessiond.sock PI_CODING_AGENT_DIR: /data/pi-agent HOSTEXEC_IMAGE: ${HOSTEXEC_IMAGE:-alpine:3.22} + HOSTEXEC_MODE: ${HOSTEXEC_MODE:-disabled} PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864} x-pi-web-volumes: &pi-web-volumes - type: bind source: ${PI_WEB_DOCKER_DATA_DIR:-./data} target: /data - - type: bind - source: /var/run/docker.sock - target: /var/run/docker.sock - - type: bind - source: /srv - target: /srv - - type: bind - source: /opt - target: /opt - - type: bind - source: /home - target: /home - - type: bind - source: / - target: /host - read_only: true services: sessiond: diff --git a/docker/install.sh b/docker/install.sh index cfd902c..5836c62 100755 --- a/docker/install.sh +++ b/docker/install.sh @@ -39,6 +39,12 @@ Options: --skip-compose Write assets/.env but skip build and service recreate -h, --help Show this help +Progressive host setup: + The installer supports native Linux Docker Engine and Docker Desktop for Mac. + Unknown Docker hosts fail closed before services are recreated. Set + PI_WEB_DOCKER_EXTRA_HOST_PATHS to a whitespace-separated list of additional + existing absolute directories to bind-mount at the same path in the containers. + Environment variables with the same names used in .env may also be set before running the installer, for example: @@ -223,30 +229,6 @@ dotenv_quote() { printf '"%s"' "$(printf '%s' "$value" | sed 's/[\\"]/\\&/g')" } -detect_docker_gid() { - if [ -S /var/run/docker.sock ]; then - if gid=$(stat -c '%g' /var/run/docker.sock 2>/dev/null); then - printf '%s\n' "$gid" - return 0 - fi - if gid=$(stat -f '%g' /var/run/docker.sock 2>/dev/null); then - printf '%s\n' "$gid" - return 0 - fi - fi - - if command -v getent >/dev/null 2>&1; then - if gid=$(getent group docker | awk -F: 'NR == 1 { print $3 }'); then - if [ -n "$gid" ]; then - printf '%s\n' "$gid" - return 0 - fi - fi - fi - - printf '0\n' -} - fetch_url() { url=$1 target=$2 @@ -292,13 +274,11 @@ write_asset() { } compose_cmd() { - if docker compose version >/dev/null 2>&1; then - docker compose "$@" - elif command -v docker-compose >/dev/null 2>&1; then - docker-compose "$@" - else - die "Docker Compose is required (docker compose plugin or docker-compose)" - fi + pi_web_docker_compose "$@" +} + +run_runtime_compose() { + compose_cmd -f compose.yml -f compose.override.yml "$@" } if [ -n "${XDG_DATA_HOME:-}" ]; then @@ -332,12 +312,37 @@ else log "Fetching Docker assets from $asset_base" fi +profile_helper_temp= +cleanup_profile_helper() { + [ -z "$profile_helper_temp" ] || rm -f "$profile_helper_temp" +} +trap cleanup_profile_helper EXIT + +if [ -n "$asset_dir" ]; then + profile_helper=$asset_dir/lib/host-profile.sh + [ -f "$profile_helper" ] || die "missing Docker asset: $profile_helper" +else + profile_helper_temp=${TMPDIR:-/tmp}/pi-web-host-profile.$$ + fetch_url "$asset_base/lib/host-profile.sh" "$profile_helper_temp" + profile_helper=$profile_helper_temp +fi + +# shellcheck source=lib/host-profile.sh +# shellcheck disable=SC1091 +. "$profile_helper" + +if ! pi_web_docker_host_detect_profile; then + pi_web_docker_host_print_detection_failure + die "refusing to install on an unsupported or unknown Docker host setup" +fi + write_asset Dockerfile 0644 write_asset compose.yml 0644 write_asset .dockerignore 0644 write_asset install.sh 0755 write_asset bin/hostexec 0755 write_asset bin/install-opensuse-base 0755 +write_asset lib/host-profile.sh 0644 custom_image_hooks_dir=$install_dir/custom-image.d mkdir -p "$custom_image_hooks_dir" || die "could not create custom image hooks directory: $custom_image_hooks_dir" @@ -347,7 +352,9 @@ fi pi_web_uid=$(value_from_env_or_default PI_WEB_UID "$(id -u)") pi_web_gid=$(value_from_env_or_default PI_WEB_GID "$(id -g)") -docker_gid=$(value_from_env_or_default DOCKER_GID "$(detect_docker_gid)") +docker_gid=$(value_from_env_or_default DOCKER_GID "$(pi_web_docker_host_detect_docker_gid)") +pi_web_host_profile=$PI_WEB_DETECTED_DOCKER_HOST_PROFILE +hostexec_mode=$PI_WEB_DETECTED_HOSTEXEC_MODE raw_data_dir=$(value_from_env_or_existing_or_default PI_WEB_DOCKER_DATA_DIR "$install_dir/data") data_dir=$(absolute_dir "$(path_from_base "$install_dir" "$raw_data_dir")") || die "could not create data directory" @@ -363,10 +370,13 @@ pi_web_extra_zypper_packages=$(value_from_env_or_existing_or_default PI_WEB_EXTR pi_web_image=$(value_from_env_or_existing_or_default PI_WEB_IMAGE pi-web:local) hostexec_image=$(value_from_env_or_existing_or_default HOSTEXEC_IMAGE alpine:3.22) pi_web_max_upload_bytes=$(value_from_env_or_existing_or_default PI_WEB_MAX_UPLOAD_BYTES 67108864) +pi_web_extra_host_paths=$(value_from_env_or_existing_or_default PI_WEB_DOCKER_EXTRA_HOST_PATHS "") require_non_empty PI_WEB_UID "$pi_web_uid" require_non_empty PI_WEB_GID "$pi_web_gid" require_non_empty DOCKER_GID "$docker_gid" +require_non_empty PI_WEB_DOCKER_HOST_PROFILE "$pi_web_host_profile" +require_non_empty HOSTEXEC_MODE "$hostexec_mode" require_non_empty PI_WEB_DOCKER_DATA_DIR "$data_dir" require_non_empty PI_WEB_BIND_ADDR "$pi_web_bind_addr" require_non_empty PI_WEB_PORT "$pi_web_port" @@ -380,6 +390,11 @@ require_non_empty HOSTEXEC_IMAGE "$hostexec_image" require_non_empty PI_WEB_MAX_UPLOAD_BYTES "$pi_web_max_upload_bytes" pi_web_extra_zypper_packages_env=$(dotenv_quote "$pi_web_extra_zypper_packages") +pi_web_extra_host_paths_env=$(dotenv_quote "$pi_web_extra_host_paths") +compose_override_file=$install_dir/compose.override.yml +if ! pi_web_docker_host_write_compose_override "$compose_override_file" "$pi_web_host_profile" "$pi_web_extra_host_paths"; then + die "could not write host-specific Compose override" +fi umask 077 temp_env=$env_file.$$ @@ -393,6 +408,11 @@ PI_WEB_UID=$pi_web_uid PI_WEB_GID=$pi_web_gid DOCKER_GID=$docker_gid +# Detected Docker host profile and host capability toggles. +PI_WEB_DOCKER_HOST_PROFILE=$pi_web_host_profile +HOSTEXEC_MODE=$hostexec_mode +PI_WEB_DOCKER_EXTRA_HOST_PATHS=$pi_web_extra_host_paths_env + # Persistent data and localhost-only default exposure. PI_WEB_DOCKER_DATA_DIR=$data_dir PI_WEB_BIND_ADDR=$pi_web_bind_addr @@ -417,6 +437,16 @@ mv "$temp_env" "$env_file" log "Wrote Docker assets to $install_dir" log "Wrote runtime environment to $env_file" +log "Wrote host Compose override to $compose_override_file" +log "Selected PI WEB Docker host profile: $pi_web_host_profile" +case "$pi_web_host_profile" in + linux-native-docker) + log "Enabled Linux host mounts and hostexec namespace bridge." + ;; + mac-docker-desktop) + log "Enabled Docker Desktop for Mac project mounts. hostexec is disabled because containers cannot enter native macOS namespaces." + ;; +esac log "Persistent PI WEB Docker data: $data_dir" log "Custom image hooks: $custom_image_hooks_dir" @@ -443,13 +473,13 @@ log "" log "Building $pi_web_image with --pull --no-cache (CACHE_BUST=$cache_bust) ..." ( cd "$install_dir" - CACHE_BUST=$cache_bust compose_cmd -f compose.yml build --pull --no-cache + CACHE_BUST=$cache_bust run_runtime_compose build --pull --no-cache ) log "Recreating split PI WEB Docker services ..." ( cd "$install_dir" - compose_cmd -f compose.yml up -d --force-recreate --remove-orphans + run_runtime_compose up -d --force-recreate --remove-orphans ) log "" @@ -458,5 +488,5 @@ log "Install directory: $install_dir" log "To update later, re-run this installer." ( cd "$install_dir" - compose_cmd -f compose.yml ps + run_runtime_compose ps ) diff --git a/docker/lib/host-profile.sh b/docker/lib/host-profile.sh new file mode 100644 index 0000000..5158e36 --- /dev/null +++ b/docker/lib/host-profile.sh @@ -0,0 +1,355 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 + +pi_web_docker_host_yaml_quote() { + value=$1 + escaped=$(printf '%s' "$value" | sed "s/'/''/g") + printf "'%s'" "$escaped" +} + +pi_web_docker_host_socket_path_from_endpoint() { + endpoint=$1 + case "$endpoint" in + unix://*) printf '%s\n' "${endpoint#unix://}" ;; + *) return 1 ;; + esac +} + +pi_web_docker_host_mac_desktop_socket_path() { + [ -n "${HOME:-}" ] || return 1 + printf '%s/.docker/run/docker.sock\n' "$HOME" +} + +pi_web_docker_host_endpoint_is_linux_expected() { + endpoint=$1 + [ "$endpoint" = unix:///var/run/docker.sock ] +} + +pi_web_docker_host_endpoint_is_mac_expected() { + endpoint=$1 + if ! socket_path=$(pi_web_docker_host_socket_path_from_endpoint "$endpoint" 2>/dev/null); then + return 1 + fi + + case "$socket_path" in + /var/run/docker.sock) + return 0 + ;; + esac + + if mac_socket_path=$(pi_web_docker_host_mac_desktop_socket_path 2>/dev/null); then + [ "$socket_path" = "$mac_socket_path" ] && return 0 + fi + + return 1 +} + +pi_web_docker_host_socket_source_for_endpoint() { + endpoint=$1 + pi_web_docker_host_socket_path_from_endpoint "$endpoint" +} + +pi_web_docker_host_detect_docker_gid() { + case "${PI_WEB_DETECTED_DOCKER_HOST_PROFILE:-}" in + mac-docker-desktop) + printf '0\n' + return 0 + ;; + esac + + socket_path=/var/run/docker.sock + if [ -n "${PI_WEB_DETECTED_DOCKER_ENDPOINT:-}" ]; then + if detected_socket_path=$(pi_web_docker_host_socket_path_from_endpoint "$PI_WEB_DETECTED_DOCKER_ENDPOINT" 2>/dev/null); then + socket_path=$detected_socket_path + fi + fi + + if [ -S "$socket_path" ]; then + if gid=$(stat -c '%g' "$socket_path" 2>/dev/null); then + printf '%s\n' "$gid" + return 0 + fi + if gid=$(stat -f '%g' "$socket_path" 2>/dev/null); then + printf '%s\n' "$gid" + return 0 + fi + fi + + if [ -S /var/run/docker.sock ]; then + if gid=$(stat -c '%g' /var/run/docker.sock 2>/dev/null); then + printf '%s\n' "$gid" + return 0 + fi + if gid=$(stat -f '%g' /var/run/docker.sock 2>/dev/null); then + printf '%s\n' "$gid" + return 0 + fi + fi + + if command -v getent >/dev/null 2>&1; then + if gid=$(getent group docker | awk -F: 'NR == 1 { print $3 }'); then + if [ -n "$gid" ]; then + printf '%s\n' "$gid" + return 0 + fi + fi + fi + + printf '0\n' +} + +pi_web_docker_host_detect_profile() { + PI_WEB_DETECTED_HOST_OS=$(uname -s 2>/dev/null || printf 'unknown') + PI_WEB_DETECTED_DOCKER_CONTEXT= + PI_WEB_DETECTED_DOCKER_ENDPOINT= + PI_WEB_DETECTED_DOCKER_HOST_ENV=${DOCKER_HOST:-} + PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT= + PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE= + PI_WEB_DETECTED_DOCKER_OS= + PI_WEB_DETECTED_DOCKER_HOST_PROFILE= + PI_WEB_DETECTED_HOSTEXEC_MODE=disabled + PI_WEB_DOCKER_HOST_PROFILE_ERROR= + + if ! command -v docker >/dev/null 2>&1; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="docker CLI is required" + return 1 + fi + + PI_WEB_DETECTED_DOCKER_CONTEXT=$(docker context show 2>/dev/null || printf 'unknown') + if [ -n "$PI_WEB_DETECTED_DOCKER_CONTEXT" ] && [ "$PI_WEB_DETECTED_DOCKER_CONTEXT" != unknown ]; then + PI_WEB_DETECTED_DOCKER_ENDPOINT=$(docker context inspect "$PI_WEB_DETECTED_DOCKER_CONTEXT" --format '{{if .Endpoints.docker}}{{.Endpoints.docker.Host}}{{end}}' 2>/dev/null || printf '') + fi + + case "$PI_WEB_DETECTED_HOST_OS" in + Linux) + if [ -n "$PI_WEB_DETECTED_DOCKER_HOST_ENV" ] && ! pi_web_docker_host_endpoint_is_linux_expected "$PI_WEB_DETECTED_DOCKER_HOST_ENV"; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="native Linux installs require DOCKER_HOST to be unset or exactly unix:///var/run/docker.sock, not $PI_WEB_DETECTED_DOCKER_HOST_ENV" + return 1 + fi + + if [ -n "$PI_WEB_DETECTED_DOCKER_ENDPOINT" ] && ! pi_web_docker_host_endpoint_is_linux_expected "$PI_WEB_DETECTED_DOCKER_ENDPOINT"; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="native Linux installs require the local /var/run/docker.sock Docker context, not $PI_WEB_DETECTED_DOCKER_ENDPOINT" + return 1 + fi + + PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT=${PI_WEB_DETECTED_DOCKER_HOST_ENV:-$PI_WEB_DETECTED_DOCKER_ENDPOINT} + PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=/var/run/docker.sock + if [ ! -S "$PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE" ]; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="native Linux installs require a local Docker socket at /var/run/docker.sock" + return 1 + fi + ;; + Darwin) + if [ -n "$PI_WEB_DETECTED_DOCKER_ENDPOINT" ] && ! pi_web_docker_host_endpoint_is_mac_expected "$PI_WEB_DETECTED_DOCKER_ENDPOINT"; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs require a Docker Desktop local Unix socket context, not $PI_WEB_DETECTED_DOCKER_ENDPOINT" + return 1 + fi + + if [ -n "$PI_WEB_DETECTED_DOCKER_HOST_ENV" ]; then + if ! pi_web_docker_host_endpoint_is_mac_expected "$PI_WEB_DETECTED_DOCKER_HOST_ENV"; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs require DOCKER_HOST to be unset or a Docker Desktop local Unix socket, not $PI_WEB_DETECTED_DOCKER_HOST_ENV" + return 1 + fi + PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT=$PI_WEB_DETECTED_DOCKER_HOST_ENV + else + PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT=$PI_WEB_DETECTED_DOCKER_ENDPOINT + fi + + if [ -n "$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT" ]; then + if ! pi_web_docker_host_endpoint_is_mac_expected "$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT"; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs require a Docker Desktop local Unix socket, not ${PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT:-unknown}" + return 1 + fi + PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=$(pi_web_docker_host_socket_source_for_endpoint "$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT") || return 1 + elif mac_socket_path=$(pi_web_docker_host_mac_desktop_socket_path 2>/dev/null) && [ -S "$mac_socket_path" ]; then + PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=$mac_socket_path + else + PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=/var/run/docker.sock + fi + + if [ ! -S "$PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE" ]; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="Docker Desktop socket is not accessible at $PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE" + return 1 + fi + ;; + *) + PI_WEB_DOCKER_HOST_PROFILE_ERROR="unsupported host OS: $PI_WEB_DETECTED_HOST_OS" + return 1 + ;; + esac + + if ! docker info >/dev/null 2>&1; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="docker daemon is not reachable by this user" + return 1 + fi + PI_WEB_DETECTED_DOCKER_OS=$(docker info --format '{{.OperatingSystem}}' 2>/dev/null || printf '') + + case "$PI_WEB_DETECTED_HOST_OS" in + Linux) + case "$PI_WEB_DETECTED_DOCKER_CONTEXT:$PI_WEB_DETECTED_DOCKER_OS" in + *desktop-linux*|*"Docker Desktop"*) + PI_WEB_DOCKER_HOST_PROFILE_ERROR="Docker Desktop on Linux is not supported by this installer because it runs containers inside a VM instead of the native Linux host" + return 1 + ;; + esac + + PI_WEB_DETECTED_DOCKER_HOST_PROFILE=linux-native-docker + PI_WEB_DETECTED_HOSTEXEC_MODE=nsenter + ;; + Darwin) + case "$PI_WEB_DETECTED_DOCKER_CONTEXT:$PI_WEB_DETECTED_DOCKER_OS:$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT" in + *desktop-linux*|*"Docker Desktop"*|*"/.docker/run/docker.sock"*) + PI_WEB_DETECTED_DOCKER_HOST_PROFILE=mac-docker-desktop + PI_WEB_DETECTED_HOSTEXEC_MODE=disabled + ;; + *) + PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs currently require Docker Desktop; detected context '$PI_WEB_DETECTED_DOCKER_CONTEXT' endpoint '${PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT:-unknown}'" + return 1 + ;; + esac + ;; + esac + + return 0 +} + +pi_web_docker_host_write_volume() { + source_path=$1 + target_path=$2 + read_only=${3:-false} + + { + printf ' - type: bind\n' + printf ' source: %s\n' "$(pi_web_docker_host_yaml_quote "$source_path")" + printf ' target: %s\n' "$(pi_web_docker_host_yaml_quote "$target_path")" + if [ "$read_only" = true ]; then + printf ' read_only: true\n' + fi + } >>"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" +} + +pi_web_docker_host_write_existing_volume() { + source_path=$1 + target_path=$2 + read_only=${3:-false} + + if [ -e "$source_path" ]; then + pi_web_docker_host_write_volume "$source_path" "$target_path" "$read_only" + fi +} + +pi_web_docker_host_write_extra_volumes() { + extra_paths=$1 + + for extra_path in $extra_paths; do + case "$extra_path" in + /*) ;; + *) + printf '%s\n' "PI_WEB_DOCKER_EXTRA_HOST_PATHS entries must be absolute paths: $extra_path" >&2 + return 1 + ;; + esac + + if [ ! -e "$extra_path" ]; then + printf '%s\n' "PI_WEB_DOCKER_EXTRA_HOST_PATHS entry does not exist: $extra_path" >&2 + return 1 + fi + + pi_web_docker_host_write_volume "$extra_path" "$extra_path" false + done +} + +pi_web_docker_host_write_compose_override() { + target_file=$1 + host_profile=$2 + extra_paths=${3:-} + target_dir=$(dirname "$target_file") + mkdir -p "$target_dir" || return 1 + PI_WEB_DOCKER_HOST_OVERRIDE_TEMP=$target_file.$$ + + case "$host_profile" in + linux-native-docker) hostexec_mode=nsenter ;; + mac-docker-desktop) hostexec_mode=disabled ;; + *) + printf '%s\n' "unsupported PI WEB Docker host profile: $host_profile" >&2 + return 1 + ;; + esac + + cat >"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" <>"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" <&2 + printf '%s\n' "" >&2 + printf '%s\n' "Detected:" >&2 + printf ' host OS: %s\n' "${PI_WEB_DETECTED_HOST_OS:-unknown}" >&2 + printf ' docker context: %s\n' "${PI_WEB_DETECTED_DOCKER_CONTEXT:-unknown}" >&2 + printf ' docker endpoint: %s\n' "${PI_WEB_DETECTED_DOCKER_ENDPOINT:-unknown}" >&2 + printf ' DOCKER_HOST: %s\n' "${PI_WEB_DETECTED_DOCKER_HOST_ENV:-unset}" >&2 + printf ' effective endpoint: %s\n' "${PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT:-unknown}" >&2 + printf ' docker socket source: %s\n' "${PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE:-unknown}" >&2 + printf ' docker OS: %s\n' "${PI_WEB_DETECTED_DOCKER_OS:-unknown}" >&2 + printf '%s\n' "" >&2 + printf '%s\n' "Supported profiles:" >&2 + printf '%s\n' " - native Linux Docker Engine using /var/run/docker.sock" >&2 + printf '%s\n' " - Docker Desktop for Mac" >&2 + if [ -n "${PI_WEB_DOCKER_HOST_PROFILE_ERROR:-}" ]; then + printf '%s\n' "" >&2 + printf 'Reason: %s\n' "$PI_WEB_DOCKER_HOST_PROFILE_ERROR" >&2 + fi +} + +pi_web_docker_compose() { + if docker compose version >/dev/null 2>&1; then + docker compose "$@" + elif command -v docker-compose >/dev/null 2>&1; then + docker-compose "$@" + else + printf '%s\n' "Docker Compose is required (docker compose plugin or docker-compose)" >&2 + return 1 + fi +} diff --git a/docker/scripts/docker-compose-dev b/docker/scripts/docker-compose-dev new file mode 100755 index 0000000..334ebb3 --- /dev/null +++ b/docker/scripts/docker-compose-dev @@ -0,0 +1,236 @@ +#!/usr/bin/env sh +set -eu + +log() { + printf '%s\n' "$*" >&2 +} + +die() { + log "pi-web Docker dev compose: $*" + exit 1 +} + +script_dir=$(unset CDPATH; cd "$(dirname "$0")" && pwd -P) +repo_root=$(unset CDPATH; cd "$script_dir/../.." && pwd -P) +dev_config_file=$repo_root/.pi-web/docker-compose-dev.local.env +legacy_dev_env_file=$repo_root/.pi-web/docker-compose-dev.env +generated_env_file=$repo_root/.pi-web/docker-compose-dev.generated.env + +# shellcheck source=../lib/host-profile.sh +# shellcheck disable=SC1091 +. "$repo_root/docker/lib/host-profile.sh" + +strip_wrapping_quotes() { + value=$1 + case "$value" in + \"*\") + case "$value" in + *\") value=${value#\"}; value=${value%\"} ;; + esac + ;; + \'*\') + case "$value" in + *\') value=${value#\'}; value=${value%\'} ;; + esac + ;; + esac + printf '%s\n' "$value" +} + +env_file_value() { + file=$1 + key=$2 + [ -f "$file" ] || return 1 + raw=$(awk -v key="$key" ' + function trim(value) { + sub(/^[ \t]+/, "", value) + sub(/[ \t\r]+$/, "", value) + return value + } + /^[ \t]*(#|$)/ { next } + { + line = $0 + sub(/^[ \t]*export[ \t]+/, "", line) + name = line + sub(/=.*/, "", name) + name = trim(name) + if (name == key) { + sub(/^[^=]*=/, "", line) + print trim(line) + found = 1 + exit + } + } + END { if (!found) exit 1 } + ' "$file") || return 1 + strip_wrapping_quotes "$raw" +} + +dev_config_value() { + env_file_value "$dev_config_file" "$1" +} + +runtime_env_value() { + env_file_value "$runtime_env_file" "$1" +} + +write_initial_dev_config() { + [ ! -e "$dev_config_file" ] || return 0 + + temp_config=$dev_config_file.$$ + previous_umask=$(umask) + umask 077 + cat >"$temp_config" <<'EOF' +# PI WEB Docker dev settings. Safe to edit. +# +# docker/scripts/docker-compose-dev creates this file once and does not +# overwrite it. Put persistent dev Docker settings here. +# +# Precedence for values used by the wrapper: +# 1. current shell environment +# 2. this file +# 3. runtime installer env, usually ~/.local/share/pi-web-docker/.env +# 4. built-in defaults +# +# Generated effective values are written to: +# .pi-web/docker-compose-dev.generated.env +# +# Bind addresses: +# - 127.0.0.1 exposes only to this machine. +# - 0.0.0.0 exposes on all host interfaces. Use only on trusted networks. +# +# Uncomment or add values to persist them. PI_WEB_DEV_API_BIND_ADDR +# controls the web/API server; PI_WEB_DEV_BIND_ADDR controls the Vite UI. +# PI_WEB_DEV_API_BIND_ADDR=127.0.0.1 +# PI_WEB_DEV_BIND_ADDR=127.0.0.1 +# PI_WEB_DEV_API_PORT=8504 +# PI_WEB_DEV_PORT=8505 +# +# Shared Docker/runtime-style defaults may also be set here: +# PI_WEB_DOCKER_DATA_DIR=/absolute/path/to/pi-web-docker/data +# PI_WEB_DOCKER_EXTRA_HOST_PATHS="/absolute/path/one /absolute/path/two" +EOF + umask "$previous_umask" + + if [ -f "$legacy_dev_env_file" ]; then + { + printf '\n%s\n' "# Values copied from the previous generated dev env file." + printf '%s\n' "# Keep, edit, or delete these lines as needed." + for key in PI_WEB_DEV_API_BIND_ADDR PI_WEB_DEV_BIND_ADDR PI_WEB_DEV_API_PORT PI_WEB_DEV_PORT; do + if value=$(env_file_value "$legacy_dev_env_file" "$key"); then + printf '%s=%s\n' "$key" "$value" + fi + done + } >>"$temp_config" + fi + + mv "$temp_config" "$dev_config_file" + log "Created user-editable dev config: $dev_config_file" +} + +value_from_env_or_config_or_runtime_or_default() { + key=$1 + default_value=$2 + eval "is_set=\${$key+x}" + if [ "${is_set:-}" = x ]; then + eval "printf '%s\n' \"\${$key}\"" + elif existing=$(dev_config_value "$key"); then + printf '%s\n' "$existing" + elif existing=$(runtime_env_value "$key"); then + printf '%s\n' "$existing" + else + printf '%s\n' "$default_value" + fi +} + +if ! pi_web_docker_host_detect_profile; then + pi_web_docker_host_print_detection_failure + die "refusing to run Docker Compose for an unsupported or unknown host setup" +fi + +runtime_env_file=${PI_WEB_DOCKER_RUNTIME_ENV_FILE:-} +if [ -z "$runtime_env_file" ] && [ -n "${HOME:-}" ]; then + runtime_env_file=$HOME/.local/share/pi-web-docker/.env +fi + +mkdir -p "$repo_root/.pi-web" || die "could not create .pi-web directory" +write_initial_dev_config + +pi_web_uid=$(value_from_env_or_config_or_runtime_or_default PI_WEB_UID "$(id -u)") +pi_web_gid=$(value_from_env_or_config_or_runtime_or_default PI_WEB_GID "$(id -g)") +docker_gid=$(value_from_env_or_config_or_runtime_or_default DOCKER_GID "$(pi_web_docker_host_detect_docker_gid)") +default_data_dir=${HOME:-$repo_root/.pi-web}/.local/share/pi-web-docker/data +pi_web_data_dir=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DOCKER_DATA_DIR "$default_data_dir") +pi_web_extra_host_paths=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DOCKER_EXTRA_HOST_PATHS "") +pi_web_opensuse_image=$(value_from_env_or_config_or_runtime_or_default PI_WEB_OPENSUSE_IMAGE opensuse/tumbleweed) +pi_web_nodejs_major=$(value_from_env_or_config_or_runtime_or_default PI_WEB_NODEJS_MAJOR 22) +pi_web_nodejs_repo=$(value_from_env_or_config_or_runtime_or_default PI_WEB_NODEJS_REPO auto) +pi_web_extra_zypper_packages=$(value_from_env_or_config_or_runtime_or_default PI_WEB_EXTRA_ZYPPER_PACKAGES "") +pi_web_dev_image=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_IMAGE pi-web:dev) +hostexec_image=$(value_from_env_or_config_or_runtime_or_default HOSTEXEC_IMAGE alpine:3.22) +pi_web_max_upload_bytes=$(value_from_env_or_config_or_runtime_or_default PI_WEB_MAX_UPLOAD_BYTES 67108864) +default_dev_bind_addr=$(value_from_env_or_config_or_runtime_or_default PI_WEB_BIND_ADDR 127.0.0.1) +pi_web_dev_api_bind_addr=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_API_BIND_ADDR "$default_dev_bind_addr") +pi_web_dev_bind_addr=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_BIND_ADDR "$default_dev_bind_addr") +pi_web_dev_api_port=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_API_PORT 8504) +pi_web_dev_port=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_PORT 8505) + +mkdir -p "$pi_web_data_dir" || die "could not create data directory: $pi_web_data_dir" + +env_file=$generated_env_file +override_file=$repo_root/.pi-web/docker-compose-dev.host.generated.yml + +if ! pi_web_docker_host_write_compose_override "$override_file" "$PI_WEB_DETECTED_DOCKER_HOST_PROFILE" "$pi_web_extra_host_paths"; then + die "could not write host-specific Compose override" +fi + +umask 077 +temp_env=$env_file.$$ +cat >"$temp_env" < Date: Sat, 27 Jun 2026 23:09:04 +0000 Subject: [PATCH 016/111] chore(changesets): consolidate docker release note --- .changeset/docker-beta-runtime-dev.md | 5 +++++ .changeset/docker-compose-home.md | 5 ----- .changeset/docker-custom-image-hooks.md | 5 ----- .changeset/docker-dev-data-permissions.md | 5 ----- .changeset/docker-development-setup.md | 5 ----- .changeset/docker-host-command-bridge.md | 5 ----- .changeset/docker-host-profiles.md | 5 ----- .changeset/docker-installer-update.md | 5 ----- .changeset/docker-shared-dev-data.md | 5 ----- .changeset/docker-usage-docs.md | 5 ----- .changeset/docker-user-name.md | 5 ----- .changeset/hostexec-container-user.md | 5 ----- .changeset/opensuse-tumbleweed-docker.md | 5 ----- 13 files changed, 5 insertions(+), 60 deletions(-) create mode 100644 .changeset/docker-beta-runtime-dev.md delete mode 100644 .changeset/docker-compose-home.md delete mode 100644 .changeset/docker-custom-image-hooks.md delete mode 100644 .changeset/docker-dev-data-permissions.md delete mode 100644 .changeset/docker-development-setup.md delete mode 100644 .changeset/docker-host-command-bridge.md delete mode 100644 .changeset/docker-host-profiles.md delete mode 100644 .changeset/docker-installer-update.md delete mode 100644 .changeset/docker-shared-dev-data.md delete mode 100644 .changeset/docker-usage-docs.md delete mode 100644 .changeset/docker-user-name.md delete mode 100644 .changeset/hostexec-container-user.md delete mode 100644 .changeset/opensuse-tumbleweed-docker.md diff --git a/.changeset/docker-beta-runtime-dev.md b/.changeset/docker-beta-runtime-dev.md new file mode 100644 index 0000000..85b2929 --- /dev/null +++ b/.changeset/docker-beta-runtime-dev.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a beta Docker runtime and development setup with local image builds, split session/web services, profile-specific host access for native Linux and Docker Desktop for Mac, shared persistent data, custom image hooks, and Docker usage documentation. diff --git a/.changeset/docker-compose-home.md b/.changeset/docker-compose-home.md deleted file mode 100644 index 31d89d7..0000000 --- a/.changeset/docker-compose-home.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Keep the Docker setup documented as beta-only, include Docker Compose/Buildx in the container images, and make the container user's home persist under `/data/home`. diff --git a/.changeset/docker-custom-image-hooks.md b/.changeset/docker-custom-image-hooks.md deleted file mode 100644 index bda892a..0000000 --- a/.changeset/docker-custom-image-hooks.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add Docker custom image hooks so local installs can add optional CLIs without bloating the default image. diff --git a/.changeset/docker-dev-data-permissions.md b/.changeset/docker-dev-data-permissions.md deleted file mode 100644 index 6a0b7ea..0000000 --- a/.changeset/docker-dev-data-permissions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Initialize shared Docker development data directory ownership before starting the dev session daemon. diff --git a/.changeset/docker-development-setup.md b/.changeset/docker-development-setup.md deleted file mode 100644 index cab3d24..0000000 --- a/.changeset/docker-development-setup.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add a Docker development setup that builds from the local checkout while keeping the session daemon separate from autoreloading web/API/client services. diff --git a/.changeset/docker-host-command-bridge.md b/.changeset/docker-host-command-bridge.md deleted file mode 100644 index b740e7d..0000000 --- a/.changeset/docker-host-command-bridge.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add a Docker runtime host command bridge for explicitly running host administration commands from containerized PI WEB sessions. diff --git a/.changeset/docker-host-profiles.md b/.changeset/docker-host-profiles.md deleted file mode 100644 index 4137426..0000000 --- a/.changeset/docker-host-profiles.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add fail-closed Docker host profile detection with Linux and Docker Desktop for Mac Compose overrides, and split Docker dev configuration into a user-editable local env file plus generated Compose inputs. diff --git a/.changeset/docker-installer-update.md b/.changeset/docker-installer-update.md deleted file mode 100644 index 2bb5d9d..0000000 --- a/.changeset/docker-installer-update.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add a one-liner Docker install/update flow that refreshes local runtime assets, preserves persistent data, rebuilds without cache, and recreates the split PI WEB services. diff --git a/.changeset/docker-shared-dev-data.md b/.changeset/docker-shared-dev-data.md deleted file mode 100644 index 3ab67da..0000000 --- a/.changeset/docker-shared-dev-data.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Share the Docker development data mount with the runtime Docker data directory by default so Pi sessions can be reused across modes. diff --git a/.changeset/docker-usage-docs.md b/.changeset/docker-usage-docs.md deleted file mode 100644 index 5de2bcc..0000000 --- a/.changeset/docker-usage-docs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Document Docker runtime and development usage, including trust warnings, update/version pinning, localhost exposure, and host command examples. diff --git a/.changeset/docker-user-name.md b/.changeset/docker-user-name.md deleted file mode 100644 index cfddfc7..0000000 --- a/.changeset/docker-user-name.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Create the Docker image `pi-web` user with the configured host UID/GID so container terminals show a normal username instead of `I have no name!`. diff --git a/.changeset/hostexec-container-user.md b/.changeset/hostexec-container-user.md deleted file mode 100644 index 6302643..0000000 --- a/.changeset/hostexec-container-user.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Run Docker host command bridge commands as the PI WEB container user by default, with `hostexec --root` for administrative commands. diff --git a/.changeset/opensuse-tumbleweed-docker.md b/.changeset/opensuse-tumbleweed-docker.md deleted file mode 100644 index 387bb50..0000000 --- a/.changeset/opensuse-tumbleweed-docker.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Move the Docker runtime and development images to openSUSE Tumbleweed with Node.js 22, npx, Corepack, and common development tooling, plus zypper-based package customization. From 7063c2c3b12cb568c33bc9d025e3d1f5cec6b9a6 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 28 Jun 2026 16:11:18 +0200 Subject: [PATCH 017/111] fix: prevent iOS input zoom --- .changeset/quiet-ios-input-zoom.md | 5 +++++ src/client/index.html | 3 +++ src/client/src/components/MachineDialog.ts | 2 +- src/client/src/components/ProjectDialog.ts | 2 +- src/client/src/components/SessionCleanupDialog.ts | 2 +- src/client/src/components/WorkspaceFilesPanel.ts | 2 +- .../src/components/settings/SettingsGeneralPanel.ts | 4 ++-- .../src/components/settings/SettingsShortcutsPanel.ts | 2 +- src/client/src/components/shared.ts | 10 +++++----- 9 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 .changeset/quiet-ios-input-zoom.md diff --git a/.changeset/quiet-ios-input-zoom.md b/.changeset/quiet-ios-input-zoom.md new file mode 100644 index 0000000..90adbfb --- /dev/null +++ b/.changeset/quiet-ios-input-zoom.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Prevent iOS Safari from zooming into small text inputs across the web UI. diff --git a/src/client/index.html b/src/client/index.html index 59dfe94..578d7db 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -11,6 +11,9 @@ -
Updatesbeta${messages.length > 0 ? html`${String(messages.length)}` : null}
+
Updates${messages.length > 0 ? html`${String(messages.length)}` : null}
${messages.length === 0 ? html`

No PI WEB update or restart messages.

` : messages.map((message) => html` @@ -160,7 +160,7 @@ const plugin: PiWebPlugin = { visible: (context) => shouldShowUpdatesPanel(context.state), badge: (context) => { const count = messageCount(context.state); - return html`beta${count > 0 ? html` · ${String(count)}` : null}`; + return count > 0 ? count : undefined; }, render: (context) => renderUpdatesPanel(html, context.terminal, context.state), }, From ad6285355de32d09de221bb234a4cae34f90459f Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 2 Jul 2026 22:17:26 +0200 Subject: [PATCH 043/111] fix: show full tool details in chat --- .changeset/horizontal-tool-targets.md | 5 ++ .../src/components/ToolExecutionView.ts | 65 +++++++++++++++---- 2 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 .changeset/horizontal-tool-targets.md diff --git a/.changeset/horizontal-tool-targets.md b/.changeset/horizontal-tool-targets.md new file mode 100644 index 0000000..e57abff --- /dev/null +++ b/.changeset/horizontal-tool-targets.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Show full chat tool file paths and commands in horizontally scrollable headers, repeat them in expanded tool details, and keep result output horizontally scrollable. diff --git a/src/client/src/components/ToolExecutionView.ts b/src/client/src/components/ToolExecutionView.ts index e573951..4fd09a2 100644 --- a/src/client/src/components/ToolExecutionView.ts +++ b/src/client/src/components/ToolExecutionView.ts @@ -4,6 +4,11 @@ import type { ToolExecutionPart } from "./shared"; const MAX_COLLAPSED_DIFF_LINES = 180; +interface ToolTarget { + label: "Command" | "File" | "Input"; + text: string; +} + @customElement("tool-execution-view") export class ToolExecutionView extends LitElement { @property({ attribute: false }) execution: ToolExecutionPart | undefined; @@ -15,7 +20,6 @@ export class ToolExecutionView extends LitElement { const execution = this.execution; if (execution === undefined) return null; - const edit = execution.toolName === "edit"; const path = pathFromArgs(execution.args); const actualDiff = diffFromDetails(execution.details); const preview = execution.preview; @@ -24,6 +28,7 @@ export class ToolExecutionView extends LitElement { const previewMismatch = actualDiff !== undefined && preview?.diff !== undefined && actualDiff !== preview.diff; const errorText = execution.status === "error" ? execution.resultText : preview?.error; const bodyText = visibleDiff === undefined ? execution.resultText : undefined; + const target = toolTarget(execution, path); return html`
@@ -31,7 +36,7 @@ export class ToolExecutionView extends LitElement {
${execution.toolName} - ${path === undefined ? html`${execution.summary}` : html`${path}`} + ${this.renderHeaderTarget(target)}
${editCountLabel(execution) === undefined ? null : html`${editCountLabel(execution)}`} @@ -42,23 +47,44 @@ export class ToolExecutionView extends LitElement { ${previewMismatch ? html`

Applied diff differs from the preview.

` : null} ${errorText === undefined || errorText === "" ? null : html`
${errorText}
`} - ${visibleDiff === undefined ? this.renderTextBody(bodyText, execution.status === "error") : this.renderDiffBody(visibleDiff, actualDiff === undefined ? "Preview diff" : "Applied diff")} - ${!edit && visibleDiff === undefined && (bodyText === undefined || bodyText === "") ? html`

${execution.summary}

` : null} + ${visibleDiff === undefined ? this.renderTextBody(bodyText, execution.status === "error", target) : this.renderDiffBody(visibleDiff, actualDiff === undefined ? "Preview diff" : "Applied diff", target)}
`; } - private renderTextBody(text: string | undefined, open: boolean) { - if (text === undefined || text === "") return null; + private renderHeaderTarget(target: ToolTarget | undefined) { + if (target === undefined) return null; + const className = target.label === "File" ? "path" : "summary"; + return html`${target.text}`; + } + + private renderExpandedTarget(target: ToolTarget | undefined) { + if (target === undefined) return null; + return html` +
+ ${target.label} +
${target.text}
+
+ `; + } + + private renderTextBody(text: string | undefined, open: boolean, target: ToolTarget | undefined) { + if ((text === undefined || text === "") && target === undefined) return null; return html`
- Result -
${text}
+ Details + ${this.renderExpandedTarget(target)} + ${text === undefined || text === "" ? null : html` +
+ Result +
${text}
+
+ `}
`; } - private renderDiffBody(diff: string, label: string) { + private renderDiffBody(diff: string, label: string, target: ToolTarget | undefined) { const lines = diff.split("\n"); const truncated = !this.showFullDiff && lines.length > MAX_COLLAPSED_DIFF_LINES; const visibleLines = truncated ? lines.slice(0, MAX_COLLAPSED_DIFF_LINES) : lines; @@ -68,6 +94,7 @@ export class ToolExecutionView extends LitElement { ${label} ${String(lines.length)} ${lines.length === 1 ? "line" : "lines"} + ${this.renderExpandedTarget(target)}
${truncated ? `Showing ${String(visibleLines.length)} of ${String(lines.length)} lines` : "Full diff"} @@ -104,10 +131,10 @@ export class ToolExecutionView extends LitElement { .tool-card.success { border-color: var(--pi-success-border); background: var(--pi-success-bg); } .tool-card.error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); } .tool-header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; min-width: 0; } - .tool-title { display: inline-flex; align-items: baseline; gap: 7px; min-width: 0; } + .tool-title { flex: 1 1 auto; display: inline-flex; align-items: baseline; gap: 7px; min-width: 0; } .status-icon { flex: 0 0 auto; color: var(--pi-muted); } - strong { color: var(--pi-text); } - .path, .summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-accent); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + strong { flex: 0 0 auto; color: var(--pi-text); } + .path, .summary { display: block; flex: 1 1 auto; min-width: 0; max-width: 100%; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; white-space: pre; color: var(--pi-accent); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; direction: ltr; text-align: left; unicode-bidi: isolate; } .summary { color: var(--pi-muted); font-family: inherit; } .tool-meta { flex: 0 0 auto; display: inline-flex; align-items: baseline; gap: 8px; color: var(--pi-muted); font-size: 12px; } .diff-stats { display: inline-flex; gap: 3px; } @@ -118,7 +145,11 @@ export class ToolExecutionView extends LitElement { .muted { margin: 0; color: var(--pi-muted); } .error-text { margin: 0; border: 1px solid var(--pi-danger); border-radius: 7px; background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); color: var(--pi-danger); padding: 8px; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .text-body { border-top: 1px solid var(--pi-border-muted); padding-top: 6px; } - .text-body pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--pi-text); } + .detail-target, .detail-result { display: grid; gap: 4px; margin-top: 8px; min-width: 0; } + .detail-label { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; } + .text-body pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--pi-text); } + .detail-result pre { box-sizing: border-box; max-width: 100%; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; border: 1px solid var(--pi-border-muted); border-radius: 7px; background: var(--pi-bg); padding: 8px; white-space: pre; overflow-wrap: normal; direction: ltr; text-align: left; unicode-bidi: isolate; } + .detail-target-value { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--pi-accent); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; direction: ltr; text-align: left; unicode-bidi: isolate; } .diff-details { min-width: 0; max-width: 100%; border-top: 1px solid var(--pi-border-muted); padding-top: 6px; } .diff-details > summary { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; color: var(--pi-muted); cursor: pointer; } .diff-details > summary span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -140,6 +171,14 @@ export class ToolExecutionView extends LitElement { `; } +function toolTarget(execution: ToolExecutionPart, path: string | undefined): ToolTarget | undefined { + if (path !== undefined && path !== "") return { label: "File", text: path }; + const command = getString(execution.args, "command"); + if (command !== undefined && command !== "") return { label: "Command", text: command }; + if (execution.summary !== "") return { label: "Input", text: execution.summary }; + return undefined; +} + function pathFromArgs(args: unknown): string | undefined { return getString(args, "path") ?? getString(args, "file_path"); } From 14d0b0f181547bf126e96ed9ea133bd279724396 Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Thu, 2 Jul 2026 20:47:39 +0000 Subject: [PATCH 044/111] docs: keep Docker setup docs scoped --- .changeset/docker-beta-runtime-dev.md | 5 ----- .changeset/docker-updates-tab.md | 5 ----- README.md | 8 -------- docs/install.html | 22 ---------------------- docs/plugins.html | 14 ++++++-------- docs/plugins.md | 16 ++++++++-------- src/docker/piWebDockerDocs.test.ts | 26 ++++++++++++++++++-------- 7 files changed, 32 insertions(+), 64 deletions(-) delete mode 100644 .changeset/docker-beta-runtime-dev.md delete mode 100644 .changeset/docker-updates-tab.md diff --git a/.changeset/docker-beta-runtime-dev.md b/.changeset/docker-beta-runtime-dev.md deleted file mode 100644 index a931c0a..0000000 --- a/.changeset/docker-beta-runtime-dev.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add a beta Docker runtime and development setup with a single `pi-web-docker` command surface, a production one-line install that does not require host Node.js, local image builds, split session/web services, profile-specific host access for native Linux and Docker Desktop for Mac, shared persistent data, custom image hooks, and Docker usage documentation. diff --git a/.changeset/docker-updates-tab.md b/.changeset/docker-updates-tab.md deleted file mode 100644 index dd83d19..0000000 --- a/.changeset/docker-updates-tab.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Expose Docker-aware PI WEB status, update, and restart commands in the Updates panel through the canonical `pi-web-docker` command, keep the Updates tab visible across federated Docker runtimes, including Docker development runtimes with explicit `pi-web-docker --dev ...` commands, and harden production and development Docker workflows around generated Compose assets, Compose project-name isolation, clearer checkout/runtime guidance, root-safety checks, UID/GID preservation, and detached helper execution. diff --git a/README.md b/README.md index e1d06bb..c1b2f30 100644 --- a/README.md +++ b/README.md @@ -80,14 +80,6 @@ pi install npm:@jmfederico/pi-web In Pi, use `/pi-web install`, `/pi-web status`, `/pi-web logs`, `/pi-web restart`, `/pi-web doctor`, and `/pi-web version`. -Docker beta runtime/server install is available when you want a local image and do not want Node.js or npm on the host: - -```bash -curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh -``` - -See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust model, supported host profiles, commands, and development mode. - ## Core model PI WEB organizes work like this: diff --git a/docs/install.html b/docs/install.html index 15339a9..cb13cf0 100644 --- a/docs/install.html +++ b/docs/install.html @@ -92,7 +92,6 @@ Requirements User service install One-line install - Docker beta install Install through Pi WSL / manual run Remote access @@ -150,27 +149,6 @@
-
-

Docker beta install

-

- The beta Docker runtime builds a local image and runs split sessiond + web services. It does not require - Node.js or npm on the host; it requires a supported Docker/Compose setup and trusted host paths. -

-
-
- Docker one-liner - -
-
$ curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh
-
-

- After installation, manage it with ~/.local/share/pi-web-docker/pi-web-docker status, update, - restart, logs, and related commands. Development mode uses - ./docker/pi-web-docker --dev <command> from a checkout. -

-

Read the Docker guide for the trust model, supported host profiles, command matrix, root-safety notes, and development mode.

-
-

Install through Pi

PI WEB is also published as a Pi package. This exposes a /pi-web command inside Pi.

diff --git a/docs/plugins.html b/docs/plugins.html index 1f3403e..18ba083 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -212,9 +212,7 @@ After editing, check the manifest endpoint and browser-console failure cases. Updates adds a conditional Updates workspace tab with PI WEB update, restart, and installed-service guidance. It is built into PI WEB, enabled by default, and uses the - selected machine's plugin copy when machine federation is active. Docker runtimes also publish a small - manifest hint so federated gateways can keep the remote Updates tab visible and expose Docker commands - while gateway status parsing catches up. + selected machine's plugin copy when machine federation is active.

  • Plugin id: updates
  • @@ -260,11 +258,11 @@ After editing, check the manifest endpoint and browser-console failure cases. { pending.status = "ready"; pending.label = file.content.match(/^DEV_URL=(.+)$/m)?.[1]; diff --git a/src/docker/piWebDockerDocs.test.ts b/src/docker/piWebDockerDocs.test.ts index 4d1b916..979e15e 100644 --- a/src/docker/piWebDockerDocs.test.ts +++ b/src/docker/piWebDockerDocs.test.ts @@ -8,17 +8,27 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const dockerOneLine = "curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh"; describe("pi-web-docker documentation", () => { - it("documents the Docker one-line install in the Docker guide, root README, and install page", async () => { - const [dockerReadme, rootReadme, installPage] = await Promise.all([ - readRepoFile("docker/README.md"), - readRepoFile("README.md"), - readRepoFile("docs/install.html"), - ]); + it("documents the Docker one-line install in the Docker guide", async () => { + const dockerReadme = await readRepoFile("docker/README.md"); expect(dockerReadme).toContain(dockerOneLine); expect(dockerReadme).toContain("does not require Node.js or npm on the host"); - expect(rootReadme).toContain(dockerOneLine); - expect(installPage).toContain(dockerOneLine); + }); + + it("keeps Docker setup documentation scoped to the Docker folder", async () => { + const nonDockerDocs = await Promise.all([ + readRepoFile("README.md"), + readRepoFile("docs/install.html"), + readRepoFile("docs/plugins.md"), + readRepoFile("docs/plugins.html"), + ]); + + for (const content of nonDockerDocs) { + expect(content).not.toContain(dockerOneLine); + expect(content).not.toContain("pi-web-docker"); + expect(content).not.toContain("Docker beta"); + expect(content).not.toContain("Docker guide"); + } }); it("keeps the Docker command matrix aligned with the canonical user command surface", async () => { From fb585261a321c627d5d836c16be99ff745c1e4a1 Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Thu, 2 Jul 2026 21:39:29 +0000 Subject: [PATCH 045/111] docs: keep README focused on quick start --- README.md | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/README.md b/README.md index 165d27d..fbbc619 100644 --- a/README.md +++ b/README.md @@ -66,20 +66,6 @@ pi-web uninstall For more install options, including one-line install, Pi package install, WSL/manual usage, and remote access, see the [installation guide](https://pi-web.dev/install). -Common alternatives: - -```bash -curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/install.sh | sh -``` - -PI WEB is also published as a Pi package: - -```bash -pi install npm:@jmfederico/pi-web -``` - -In Pi, use `/pi-web install`, `/pi-web status`, `/pi-web logs`, `/pi-web restart`, `/pi-web doctor`, and `/pi-web version`. - ## Core model PI WEB organizes work like this: @@ -169,24 +155,6 @@ npm run dev:web npm run dev:client ``` -Or install the split development setup as native per-user services from the checkout: - -```bash -pi-web install --dev -``` - -`pi-web install --dev` writes the session daemon plus a UI development service using the native user-service backend. `pi-web uninstall` removes both production and development service files; no uninstall flags are needed. - -`dev:web` also watches bundled plugin TypeScript and rebuilds the browser-loaded plugin JavaScript under `dist/pi-web-plugins/`. You can restart `dev:web` or `dev:client` without stopping active Pi sessions. - -For a production-style run from a checkout: - -```bash -npm run build -npm run start:sessiond -PI_WEB_PORT=8504 npm start -``` - Validate changes with: ```bash From 031aee3efe5b0a4fd99db8cc97e947b1450d8bae Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Thu, 2 Jul 2026 21:52:24 +0000 Subject: [PATCH 046/111] test: skip Docker command executions on Windows --- src/server/dockerControlAssets.test.ts | 30 +++++++++++++++----------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/server/dockerControlAssets.test.ts b/src/server/dockerControlAssets.test.ts index e044e5e..00febfa 100644 --- a/src/server/dockerControlAssets.test.ts +++ b/src/server/dockerControlAssets.test.ts @@ -30,6 +30,10 @@ afterEach(async () => { }); describe("Docker command assets", () => { + // The Docker control shell scripts intentionally support Linux and macOS hosts. + // Keep Windows CI on static/syntax coverage instead of executing POSIX host-path and socket flows there. + const dockerCommandIt = it.skipIf(process.platform === "win32"); + it("keeps shell entrypoints syntactically valid", async () => { await Promise.all([ execUtf8("sh", ["-n", dockerEntrypoint], process.env), @@ -78,7 +82,7 @@ describe("Docker command assets", () => { expect(devCompose).toContain("COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web-dev}"); }); - it("runs status through Docker Compose in the foreground", async () => { + dockerCommandIt("runs status through Docker Compose in the foreground", async () => { const installDir = await createRuntimeInstall(); const fakeDocker = await installFakeDocker(); @@ -91,7 +95,7 @@ describe("Docker command assets", () => { expect(log).not.toContain("run -d"); }); - it("runs production host lifecycle commands through the generated runtime env", async () => { + dockerCommandIt("runs production host lifecycle commands through the generated runtime env", async () => { const installDir = await createRuntimeInstall(); const fakeDocker = await installFakeDocker(); const env = runtimeHostEnv(fakeDocker, installDir); @@ -113,7 +117,7 @@ describe("Docker command assets", () => { expect(log).not.toContain("run -d"); }); - it("ignores ambient Compose project names for runtime lifecycle commands", async () => { + dockerCommandIt("ignores ambient Compose project names for runtime lifecycle commands", async () => { const installDir = await createRuntimeInstall(); const fakeDocker = await installFakeDocker(); @@ -127,7 +131,7 @@ describe("Docker command assets", () => { expect(log).not.toContain("--project-name ambient-project"); }); - it("runs development commands through generated env while preserving host user ids", async () => { + dockerCommandIt("runs development commands through generated env while preserving host user ids", async () => { const devRoot = await createDevRepoFixture(); const fakeDocker = await installFakeDocker(); await installFakeUname(fakeDocker.binDir, "Darwin"); @@ -181,7 +185,7 @@ describe("Docker command assets", () => { expect(log).toContain(`compose --project-name pi-web-dev --env-file ${generatedEnvPath} -f ${devRoot}/docker/compose.dev.yml -f ${devRoot}/.pi-web/docker-compose-dev.host.generated.yml ps`); }); - it("rejects development commands as root unless explicitly allowed", async () => { + dockerCommandIt("rejects development commands as root unless explicitly allowed", async () => { const fakeDocker = await installFakeDocker(); await installFakeId(fakeDocker.binDir, 0, 0); @@ -194,7 +198,7 @@ describe("Docker command assets", () => { expect(result.stderr).toContain("refusing to run Docker development mode as root"); }); - it("passes the root override through to the development compose helper", async () => { + dockerCommandIt("passes the root override through to the development compose helper", async () => { const fakeDocker = await installFakeDocker(); await installFakeId(fakeDocker.binDir, 0, 0); const helperLog = join(tempDir, "dev-helper.log"); @@ -209,7 +213,7 @@ describe("Docker command assets", () => { expect(await readFile(helperLog, "utf8")).toBe("allow=1 args=ps\n"); }); - it("starts development detached helpers as the generated dev user", async () => { + dockerCommandIt("starts development detached helpers as the generated dev user", async () => { const devRoot = await createDevGeneratedEnv({ uid: 1234, gid: 2345, dockerGid: 3456 }); const fakeDocker = await installFakeDocker(); await installFakeId(fakeDocker.binDir, 1234, 2345); @@ -230,7 +234,7 @@ describe("Docker command assets", () => { expect(log).not.toContain("--user 0:0"); }); - it("rejects inside-container commands whose explicit mode does not match the container mode", async () => { + dockerCommandIt("rejects inside-container commands whose explicit mode does not match the container mode", async () => { const result = await runDockerCommandAllowFailure(["restart-sessiond"], { ...cleanProcessEnv(), PI_WEB_DOCKER_RUNTIME: "1", @@ -241,13 +245,13 @@ describe("Docker command assets", () => { expect(result.stderr).toContain("this PI WEB Docker container is in dev mode"); }); - it("routes production install to the bootstrap installer", async () => { + dockerCommandIt("routes production install to the bootstrap installer", async () => { const result = await runDockerCommand(["install", "--help"], cleanProcessEnv()); expect(result.stdout).toContain("Usage: docker/install.sh [options]"); }); - it("explains source checkout runtime-mode mistakes", async () => { + dockerCommandIt("explains source checkout runtime-mode mistakes", async () => { const fakeDocker = await installFakeDocker(); const result = await runDockerCommandAllowFailure(["start"], { @@ -264,7 +268,7 @@ describe("Docker command assets", () => { expect(result.stderr).toContain("PI_WEB_DOCKER_INSTALL_DIR"); }); - it("starts restart-sessiond in a detached Docker helper", async () => { + dockerCommandIt("starts restart-sessiond in a detached Docker helper", async () => { const installDir = await createRuntimeInstall(); const fakeDocker = await installFakeDocker(); @@ -296,7 +300,7 @@ describe("Docker command assets", () => { expect(log).not.toContain("compose -f compose.yml -f compose.override.yml restart sessiond"); }); - it("executes the detached restart-sessiond action through Compose", async () => { + dockerCommandIt("executes the detached restart-sessiond action through Compose", async () => { const installDir = await createRuntimeInstall(); const fakeDocker = await installFakeDocker(); @@ -307,7 +311,7 @@ describe("Docker command assets", () => { expect(log).not.toContain("run -d"); }); - it("executes the detached runtime update action through Compose without nesting helpers", async () => { + dockerCommandIt("executes the detached runtime update action through Compose without nesting helpers", async () => { const installDir = await createRuntimeInstall(); const fakeDocker = await installFakeDocker(); From 1564f1cfc54fae3ab887ed898f2f6ee298e41e3a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 3 Jul 2026 09:44:46 +0200 Subject: [PATCH 047/111] test: audit existing suite --- .../appShell/viewportPositionRepair.test.ts | 24 +++++- src/client/src/cachedNewSessions.test.ts | 9 ++- src/client/src/chatScrollPosition.test.ts | 2 +- .../src/components/selectableRow.test.ts | 4 +- .../settings/SettingsGeneralPanel.test.ts | 19 ----- .../settings/SettingsPackagesPanel.test.ts | 2 +- .../settings/SettingsPanelFrame.test.ts | 14 +++- .../settings/settingsConfigDraft.test.ts | 78 +++++-------------- .../settings/settingsConfigDraft.ts | 16 ---- .../settings/settingsSessiondConfig.test.ts | 2 - .../src/controllers/sessionController.test.ts | 22 +++++- .../src/controllers/sessionSelection.test.ts | 8 +- .../controllers/workspaceSelection.test.ts | 2 +- src/client/src/inputModes.test.ts | 10 +-- src/client/src/plugins/registry.test.ts | 7 +- src/client/src/route.test.ts | 17 ++-- src/client/src/theme.test.ts | 12 ++- src/client/src/workspaceActivity.test.ts | 8 +- src/client/src/workspaceDeletion.test.ts | 14 +++- src/config.test.ts | 18 ++++- src/server/configRoutes.test.ts | 30 ++++++- src/server/dockerControlAssets.test.ts | 2 +- src/server/machines/machineService.test.ts | 4 +- src/server/piPackageRoutes.test.ts | 2 + src/server/piWebPluginService.test.ts | 27 +++++-- src/server/piWebStatus.test.ts | 3 +- .../sessions/authProviderOptions.test.ts | 4 +- src/server/sessions/piSessionService.test.ts | 29 +++---- .../sessions/sessionCommandService.test.ts | 17 +++- src/server/sessions/spawnSessionTool.test.ts | 13 +--- .../sessions/subsessionTranscript.test.ts | 8 +- src/server/terminals/terminalRoutes.test.ts | 2 +- src/server/workingDirectory.test.ts | 6 +- .../workspaces/fileContentService.test.ts | 28 ++++--- .../workspaceDeletionRoutes.test.ts | 2 +- src/shared/piWebStatusParsing.test.ts | 4 +- src/shared/promptAttachments.test.ts | 18 ++--- 37 files changed, 256 insertions(+), 231 deletions(-) diff --git a/src/client/src/appShell/viewportPositionRepair.test.ts b/src/client/src/appShell/viewportPositionRepair.test.ts index 588ad69..302ed86 100644 --- a/src/client/src/appShell/viewportPositionRepair.test.ts +++ b/src/client/src/appShell/viewportPositionRepair.test.ts @@ -80,15 +80,27 @@ describe("ViewportPositionRepairer", () => { const timer = firstMapEntry(scheduler.timers); expect(timer[1].delayMs).toBe(VIEWPORT_POSITION_REPAIR_DELAY_MS); + scheduler.documentElement.scrollTop = 56; + scheduler.body.scrollTop = 78; scheduler.runAnimationFrame(firstFrame); - expect(scheduler.scrollCalls).toHaveLength(2); + expect(scheduler.scrollCalls).toEqual([[0, 0], [0, 0]]); + expect(scheduler.documentElement.scrollTop).toBe(0); + expect(scheduler.body.scrollTop).toBe(0); const secondFrame = firstMapKey(scheduler.animationFrames); + scheduler.documentElement.scrollTop = 90; + scheduler.body.scrollTop = 123; scheduler.runAnimationFrame(secondFrame); - expect(scheduler.scrollCalls).toHaveLength(3); + expect(scheduler.scrollCalls).toEqual([[0, 0], [0, 0], [0, 0]]); + expect(scheduler.documentElement.scrollTop).toBe(0); + expect(scheduler.body.scrollTop).toBe(0); + scheduler.documentElement.scrollTop = 34; + scheduler.body.scrollTop = 12; scheduler.runTimer(timer[0]); - expect(scheduler.scrollCalls).toHaveLength(4); + expect(scheduler.scrollCalls).toEqual([[0, 0], [0, 0], [0, 0], [0, 0]]); + expect(scheduler.documentElement.scrollTop).toBe(0); + expect(scheduler.body.scrollTop).toBe(0); }); it("replaces pending scheduled repairs", () => { @@ -102,6 +114,10 @@ describe("ViewportPositionRepairer", () => { expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]); expect(scheduler.clearedTimers).toEqual([firstTimer]); + expect(scheduler.animationFrames.has(firstFrame)).toBe(false); + expect(scheduler.timers.has(firstTimer)).toBe(false); + expect(scheduler.animationFrames.size).toBe(1); + expect(scheduler.timers.size).toBe(1); }); it("clears pending work when repair is no longer needed", () => { @@ -115,5 +131,7 @@ describe("ViewportPositionRepairer", () => { expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]); expect(scheduler.clearedTimers).toEqual([firstTimer]); + expect(scheduler.animationFrames.size).toBe(0); + expect(scheduler.timers.size).toBe(0); }); }); diff --git a/src/client/src/cachedNewSessions.test.ts b/src/client/src/cachedNewSessions.test.ts index 85e235f..bd1f976 100644 --- a/src/client/src/cachedNewSessions.test.ts +++ b/src/client/src/cachedNewSessions.test.ts @@ -57,9 +57,12 @@ describe("cached new sessions", () => { rememberCachedNewSession(baseSession, "local", storage); rememberCachedNewSession({ ...baseSession, id: "other", cwd: "/other" }, "local", storage); - expect(mergeCachedNewSessions("/repo", [], "local", storage).map((session) => session.id)).toEqual(["session-1"]); - expect(mergeCachedNewSessions("/repo", [baseSession], "local", storage).map((session) => session.id)).toEqual(["session-1"]); - expect(isCachedNewSessionInfo(mergeCachedNewSessions("/repo", [baseSession], "local", storage)[0])).toBe(false); + const cachedOnly = mergeCachedNewSessions("/repo", [], "local", storage); + const mergedWithServerSession = mergeCachedNewSessions("/repo", [baseSession], "local", storage); + + expect(cachedOnly.map((session) => session.id)).toEqual(["session-1"]); + expect(mergedWithServerSession.map((session) => session.id)).toEqual(["session-1"]); + expect(isCachedNewSessionInfo(mergedWithServerSession[0])).toBe(false); expect(loadCachedNewSessions(storage).map((session) => session.id)).toEqual(["other"]); }); diff --git a/src/client/src/chatScrollPosition.test.ts b/src/client/src/chatScrollPosition.test.ts index 01c32d2..5284432 100644 --- a/src/client/src/chatScrollPosition.test.ts +++ b/src/client/src/chatScrollPosition.test.ts @@ -135,7 +135,7 @@ describe("ChatScrollController", () => { expect(JSON.parse(storage.getItem(key) ?? "{}")).toEqual({ mode: "bottom" }); }); - it("captures the session id when scheduling a delayed save", () => { + it("cancels the previous delayed save and passes the latest session id", () => { const scheduler = new ManualScheduler(); const controller = new ChatScrollController(new MemoryScrollStorage(), scheduler); const saved: string[] = []; diff --git a/src/client/src/components/selectableRow.test.ts b/src/client/src/components/selectableRow.test.ts index ce63d26..a49255b 100644 --- a/src/client/src/components/selectableRow.test.ts +++ b/src/client/src/components/selectableRow.test.ts @@ -8,7 +8,7 @@ describe("selectable row activation", () => { expect(action).toHaveBeenCalledOnce(); }); - it("preserves contributed links and other interactive elements", () => { + it("preserves contributed links inside rows", () => { const action = vi.fn(); activateSelectableRow(eventWithPath(matchTarget((selector: string) => selector.includes("a[href]"))), action); expect(action).not.toHaveBeenCalled(); @@ -57,6 +57,8 @@ describe("selectable row activation", () => { expect(handleSelectableRowKeyboard(event, { activate: vi.fn(), cancel })).toBe(true); expect(cancel).toHaveBeenCalledOnce(); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(event.stopPropagation).toHaveBeenCalledOnce(); }); }); diff --git a/src/client/src/components/settings/SettingsGeneralPanel.test.ts b/src/client/src/components/settings/SettingsGeneralPanel.test.ts index 9c0250a..570c182 100644 --- a/src/client/src/components/settings/SettingsGeneralPanel.test.ts +++ b/src/client/src/components/settings/SettingsGeneralPanel.test.ts @@ -123,25 +123,6 @@ describe("settings-general-panel save payloads", () => { expect(getPanelProperty(panel, "machineLocalError")).toBe(""); }); - it("clears upload defaults with a selected-machine-safe patch", async () => { - const panel = new SettingsGeneralPanel(); - const onSaveMachineConfig = vi.fn(); - panel.onSaveMachineConfig = onSaveMachineConfig; - setPanelProperty(panel, "machineDraft", { - allowedPathsText: "", - uploadDefaultFolder: "", - } satisfies MachineAccessConfigDraft); - - await callPanelPromise(panel, "saveMachineAccessConfig", new Event("submit", { cancelable: true })); - - expect(onSaveMachineConfig.mock.calls).toEqual([[ - { - pathAccess: { allowedPaths: [] }, - uploads: {}, - }, - ]]); - }); - it("keeps invalid upload folders local and does not save selected-machine config", async () => { const panel = new SettingsGeneralPanel(); const onSaveMachineConfig = vi.fn(); diff --git a/src/client/src/components/settings/SettingsPackagesPanel.test.ts b/src/client/src/components/settings/SettingsPackagesPanel.test.ts index 7e97ea9..e639549 100644 --- a/src/client/src/components/settings/SettingsPackagesPanel.test.ts +++ b/src/client/src/components/settings/SettingsPackagesPanel.test.ts @@ -59,7 +59,7 @@ describe("settings-packages-panel layout", () => { expect(rendered).not.toContain("Pi package list unavailable"); }); - it("orders package load errors before the trusted-code warning while preserving loaded data", () => { + it("orders package errors before the trusted-code warning while preserving loaded data", () => { const panel = new SettingsPackagesPanel(); panel.targetMachine = remoteTarget; panel.packagesResponse = { packages: [packageInfo("npm:@acme/tools")] }; diff --git a/src/client/src/components/settings/SettingsPanelFrame.test.ts b/src/client/src/components/settings/SettingsPanelFrame.test.ts index 46cc2d0..0eafee2 100644 --- a/src/client/src/components/settings/SettingsPanelFrame.test.ts +++ b/src/client/src/components/settings/SettingsPanelFrame.test.ts @@ -28,7 +28,7 @@ describe("settings-panel-frame", () => { expect(rendered.indexOf('class="notice-stack"')).toBeLessThan(rendered.indexOf('class="content"')); }); - it("maps notice types to consistent default tones and roles", () => { + it("maps representative notice types to consistent default tones and roles", () => { const notices: readonly SettingsNotice[] = [ { type: "availability", content: "Configuration unavailable." }, { type: "success", content: "Saved." }, @@ -41,7 +41,12 @@ describe("settings-panel-frame", () => { const values = collectTemplateValues(frame.render()); expect(notices.map(settingsNoticeTone)).toEqual(["error", "success", "warning", "info"]); - expect(values).toEqual(expect.arrayContaining(["notice error", "alert", "notice success", "status", "notice warning", "note", "notice info"])); + expect(values.filter(isNoticeClassOrRole)).toEqual([ + "notice error", "alert", + "notice success", "status", + "notice warning", "note", + "notice info", "note", + ]); }); it("wires the default header action through the frame", () => { @@ -145,3 +150,8 @@ function isStringArray(value: unknown): value is string[] { function isActionHandler(value: unknown): value is () => void { return typeof value === "function"; } + +function isNoticeClassOrRole(value: unknown): value is string { + return typeof value === "string" + && (value.startsWith("notice ") || value === "alert" || value === "status" || value === "note"); +} diff --git a/src/client/src/components/settings/settingsConfigDraft.test.ts b/src/client/src/components/settings/settingsConfigDraft.test.ts index febf6ee..6e13e75 100644 --- a/src/client/src/components/settings/settingsConfigDraft.test.ts +++ b/src/client/src/components/settings/settingsConfigDraft.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from "vitest"; import { - configFromDraft, - draftFromConfig, gatewayServerConfigFromDraft, gatewayServerDraftFromConfig, machineAccessConfigPatchFromDraft, @@ -28,28 +26,42 @@ describe("settings config drafts", () => { allowedPathsText: "/tmp\n~/SDKs", uploadDefaultFolder: "manual/uploads", }); + expect(gatewayServerDraftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all"); }); - it("builds gateway server saves without changing selected-machine-safe config values", () => { + it("builds gateway server saves without dropping preserved config values", () => { expect(gatewayServerConfigFromDraft({ host: " gateway.local ", port: "9000", allowedHostsMode: "all", allowedHostsText: "ignored.local", }, { + shortcuts: { "core:view.chat": "mod+1" }, + plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "old/uploads" }, maxUploadBytes: 1234, spawnSessions: true, + subsessions: false, })).toEqual({ host: "gateway.local", port: 9000, allowedHosts: true, + shortcuts: { "core:view.chat": "mod+1" }, + plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "old/uploads" }, maxUploadBytes: 1234, spawnSessions: true, + subsessions: false, }); + + expect(gatewayServerConfigFromDraft({ + host: "", + port: "", + allowedHostsMode: "list", + allowedHostsText: "example.local, 192.168.1.20\n", + })).toEqual({ allowedHosts: ["example.local", "192.168.1.20"] }); }); it("builds selected-machine access/upload patches only from selected-machine-safe fields", () => { @@ -77,64 +89,10 @@ describe("settings config drafts", () => { expect(() => machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "../secret" })).toThrow("Upload default folder must not contain path traversal."); }); - it("converts PI WEB config values to editable general settings drafts", () => { - expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"], pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } })).toEqual({ - host: "0.0.0.0", - port: "8504", - allowedHostsMode: "list", - allowedHostsText: "example.local\n192.168.1.20", - allowedPathsText: "/tmp\n~/SDKs", - }); - expect(draftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all"); - }); - - it("converts drafts back to config while preserving non-general preferences", () => { - expect(configFromDraft({ - host: " 127.0.0.1 ", - port: "9000", - allowedHostsMode: "list", - allowedHostsText: "example.local, 192.168.1.20\n", - allowedPathsText: "/tmp\n~/SDKs\n", - }, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 })).toEqual({ - host: "127.0.0.1", - port: 9000, - allowedHosts: ["example.local", "192.168.1.20"], - shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, - plugins: { info: { enabled: false } }, - pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, - uploads: { defaultFolder: "manual/uploads" }, - maxUploadBytes: 1234, - }); - }); - - it("removes global path access when the allowed paths field is cleared", () => { - expect(configFromDraft({ - host: "", - port: "", - allowedHostsMode: "list", - allowedHostsText: "", - allowedPathsText: "", - }, { pathAccess: { allowedPaths: ["/old"] } })).not.toHaveProperty("pathAccess"); - }); - - it("rejects relative external paths before saving", () => { - expect(() => configFromDraft({ - host: "", - port: "", - allowedHostsMode: "list", - allowedHostsText: "", + it("rejects relative external paths before saving selected-machine access", () => { + expect(() => machineAccessConfigPatchFromDraft({ allowedPathsText: "relative/path", + uploadDefaultFolder: "", })).toThrow("Allowed external paths must be absolute paths or start with ~"); }); - - it("preserves the spawnSessions flag when saving general settings", () => { - const result = configFromDraft({ - host: "", - port: "", - allowedHostsMode: "list", - allowedHostsText: "", - allowedPathsText: "", - }, { spawnSessions: true }); - expect(result.spawnSessions).toBe(true); - }); }); diff --git a/src/client/src/components/settings/settingsConfigDraft.ts b/src/client/src/components/settings/settingsConfigDraft.ts index 90e4606..917b9d4 100644 --- a/src/client/src/components/settings/settingsConfigDraft.ts +++ b/src/client/src/components/settings/settingsConfigDraft.ts @@ -12,10 +12,6 @@ export interface MachineAccessConfigDraft { uploadDefaultFolder: string; } -export interface ConfigDraft extends GatewayServerConfigDraft { - allowedPathsText: string; -} - export function emptyGatewayServerConfigDraft(): GatewayServerConfigDraft { return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" }; } @@ -40,10 +36,6 @@ export function machineAccessDraftFromConfig(config: PiWebConfigValues): Machine }; } -export function draftFromConfig(config: PiWebConfigValues): ConfigDraft { - return { ...gatewayServerDraftFromConfig(config), allowedPathsText: machineAccessDraftFromConfig(config).allowedPathsText }; -} - export function gatewayServerConfigFromDraft(draft: GatewayServerConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues { const config = preservedGatewayConfigRemainder(baseConfig); const host = draft.host.trim(); @@ -67,14 +59,6 @@ export function machineAccessConfigPatchFromDraft(draft: MachineAccessConfigDraf }; } -export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues { - const config = gatewayServerConfigFromDraft(draft, baseConfig); - const allowedPaths = parseAllowedPathsText(draft.allowedPathsText); - if (allowedPaths.length > 0) config.pathAccess = { allowedPaths }; - else delete config.pathAccess; - return config; -} - function preservedGatewayConfigRemainder(baseConfig: PiWebConfigValues): PiWebConfigValues { return { ...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }), diff --git a/src/client/src/components/settings/settingsSessiondConfig.test.ts b/src/client/src/components/settings/settingsSessiondConfig.test.ts index b669882..f3451e3 100644 --- a/src/client/src/components/settings/settingsSessiondConfig.test.ts +++ b/src/client/src/components/settings/settingsSessiondConfig.test.ts @@ -5,9 +5,7 @@ import { mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessio describe("session daemon settings config helpers", () => { it("builds daemon-only save patches for the sessiond toggles", () => { expect(spawnSessionsConfigPatch(false)).toEqual({ spawnSessions: false }); - expect(Object.keys(spawnSessionsConfigPatch(false))).toEqual(["spawnSessions"]); expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true }); - expect(Object.keys(subsessionsConfigPatch(true))).toEqual(["subsessions"]); }); it("merges local selected-machine daemon config into gateway config without dropping gateway-only values", () => { diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 2baf0fe..385de44 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -3,6 +3,7 @@ import { api as defaultApi, type MessagePage, type PromptAttachment, type Sessio import type { SessionUiEvent } from "../sessionSocket"; import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions"; import { initialAppState, type AppState } from "../appState"; +import { ChatTranscriptStore } from "../chatTranscriptStore"; import { machineSessionKey } from "../machineKeys"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { loadDraft, saveDraft } from "../promptDraftStorage"; @@ -1414,8 +1415,10 @@ describe("SessionController", () => { }); it("reloads the selected session from disk, discards the cached transcript, and re-fetches history", async () => { - Object.defineProperty(globalThis, "localStorage", { value: new MemoryStorage(), configurable: true }); const persistedSession = { ...oldSession, persisted: true }; + const cacheKey = sessionKey(oldSession.id); + const freshPage: MessagePage = { messages: [{ role: "assistant", content: "fresh from disk" }], start: 1, total: 2 }; + const cachedPages = new Map([[cacheKey, { messages: [{ role: "user", content: "stale cached transcript" }], start: 0, total: 2 }]]); const reloadCalls: string[] = []; const messageCalls: string[] = []; let state: AppState = { @@ -1433,7 +1436,7 @@ describe("SessionController", () => { }, messages: (session) => { messageCalls.push(sessionLookupId(session)); - return Promise.resolve(emptyPage); + return Promise.resolve(freshPage); }, status: (session) => Promise.resolve(status(sessionLookupId(session))), }; @@ -1442,13 +1445,24 @@ describe("SessionController", () => { (patch) => { state = { ...state, ...patch }; }, () => undefined, new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, + { + api, + socket: new FakeSocket(), + transcripts: new ChatTranscriptStore({ + read: (sessionId) => cachedPages.get(sessionId), + write: (sessionId, page) => { cachedPages.set(sessionId, page); }, + remove: (sessionId) => { cachedPages.delete(sessionId); }, + }), + }, ); await controller.reloadSession(persistedSession); expect(reloadCalls).toEqual([oldSession.id]); - expect(messageCalls).toContain(oldSession.id); + expect(messageCalls).toEqual([oldSession.id]); + expect(cachedPages.get(cacheKey)).toEqual(freshPage); + expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "fresh from disk" }] }]); + expect(state.messagePageStart).toBe(1); expect(state.error).toBe(""); }); diff --git a/src/client/src/controllers/sessionSelection.test.ts b/src/client/src/controllers/sessionSelection.test.ts index 6880259..8c13774 100644 --- a/src/client/src/controllers/sessionSelection.test.ts +++ b/src/client/src/controllers/sessionSelection.test.ts @@ -28,18 +28,12 @@ describe("selectPreferredSession", () => { expect(selectPreferredSession(sessions)).toBeUndefined(); }); - it("can remember an archived selected session", () => { + it("returns a remembered archived session before falling back to active sessions", () => { const sessions = [{ ...testSession("s1"), archived: true }, testSession("s2")]; expect(selectPreferredSession(sessions, { latestSessionId: "s1" })?.id).toBe("s1"); }); - it("can remember an archived selected session when only archived sessions remain", () => { - const sessions = [{ ...testSession("s1"), archived: true }]; - - expect(selectPreferredSession(sessions, { latestSessionId: "s1" })?.id).toBe("s1"); - }); - it("falls back to the first active session when the remembered session no longer exists", () => { const sessions = [{ ...testSession("s1"), archived: true }, testSession("s2")]; diff --git a/src/client/src/controllers/workspaceSelection.test.ts b/src/client/src/controllers/workspaceSelection.test.ts index 477d3f9..a2a8af1 100644 --- a/src/client/src/controllers/workspaceSelection.test.ts +++ b/src/client/src/controllers/workspaceSelection.test.ts @@ -22,7 +22,7 @@ describe("selectPreferredWorkspace", () => { expect(selectPreferredWorkspace(workspaces, { latestWorkspaceId: "old" })?.id).toBe("main"); }); - it("preserves explicit invalid target behavior", () => { + it("does not fall back to remembered workspace when the explicit target is invalid", () => { const workspaces = [testWorkspace("main"), testWorkspace("feature")]; expect(selectPreferredWorkspace(workspaces, { targetWorkspaceId: "old", latestWorkspaceId: "feature" })).toBeUndefined(); diff --git a/src/client/src/inputModes.test.ts b/src/client/src/inputModes.test.ts index f4e8ea3..20e84df 100644 --- a/src/client/src/inputModes.test.ts +++ b/src/client/src/inputModes.test.ts @@ -1,27 +1,27 @@ import { describe, expect, it } from "vitest"; import { inputModeForDraft, inputModesEqual, isShellInput } from "./inputModes"; -describe("inputModeForDraft", () => { - it("detects shell input and context-excluded shell input after leading whitespace", () => { +describe("input mode helpers", () => { + it("detects shell mode and context-excluded shell mode after leading whitespace", () => { expect(inputModeForDraft(" ! npm test")).toEqual({ kind: "shell", excludeFromContext: false }); expect(inputModeForDraft("\n!! secret command")).toEqual({ kind: "shell", excludeFromContext: true }); expect(isShellInput(" ! pwd")).toBe(true); }); - it("detects slash commands only for the current token", () => { + it("detects slash-command mode from the current token", () => { expect(inputModeForDraft("/compact")).toEqual({ kind: "command" }); expect(inputModeForDraft("please /compact")).toEqual({ kind: "command" }); expect(inputModeForDraft("please mention/path")).toEqual({ kind: "normal" }); }); - it("treats modes as equal only when kind and shell context-exclusion match", () => { + it("compares modes by kind and shell context-exclusion", () => { expect(inputModesEqual({ kind: "normal" }, { kind: "normal" })).toBe(true); expect(inputModesEqual({ kind: "normal" }, { kind: "command" })).toBe(false); expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: false })).toBe(true); expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: true })).toBe(false); }); - it("detects file completion contexts", () => { + it("collapses file completion triggers to file mode", () => { expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" }); expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" }); expect(inputModeForDraft("open @ A FILE")).toEqual({ kind: "file" }); diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 46161c1..ce47538 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -64,7 +64,7 @@ describe("PluginRegistry", () => { expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git", "core:workspace.terminal"]); }); - it("provides html and svg helpers to plugin activation", () => { + it("provides html and svg helpers to plugin activation and callbacks", () => { const registry = new PluginRegistry(); registry.register({ id: "example", @@ -86,7 +86,10 @@ describe("PluginRegistry", () => { }, }); - expect(registry.getWorkspacePanels()[0]?.icon).toBeDefined(); + const panel = registry.getWorkspacePanels()[0]; + + expect(panel?.icon).toBeDefined(); + expect(panel?.render(createWorkspacePanelContext("local"))).toBeDefined(); }); it("exposes the prompt helper to workspace panel callbacks", () => { diff --git a/src/client/src/route.test.ts b/src/client/src/route.test.ts index c270bda..b82f7bc 100644 --- a/src/client/src/route.test.ts +++ b/src/client/src/route.test.ts @@ -8,9 +8,10 @@ afterEach(() => { Object.defineProperty(globalThis, "window", { value: originalWindow, configurable: true }); }); -function installWindow(href: string): { pushed: string[] } { +function installWindow(href: string): { pushed: string[]; replaced: string[] } { const url = new URL(href); const pushed: string[] = []; + const replaced: string[] = []; const fakeWindow = { location: { href: url.href, @@ -23,12 +24,12 @@ function installWindow(href: string): { pushed: string[] } { pushed.push(String(next)); }), replaceState: vi.fn((_state: object, _title: string, next: URL | string) => { - pushed.push(String(next)); + replaced.push(String(next)); }), }, }; Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true }); - return { pushed }; + return { pushed, replaced }; } describe("route helpers", () => { @@ -51,8 +52,8 @@ describe("route helpers", () => { expect(readRoute()).toMatchObject({ tool: undefined, view: undefined }); }); - it("writes compact URLs and preserves path/hash", () => { - const { pushed } = installWindow("http://localhost/app?old=1#section"); + it("writes compact URLs with push history and preserves path/hash", () => { + const { pushed, replaced } = installWindow("http://localhost/app?old=1#section"); const route: AppRoute = { machineId: "remote", projectId: "project/id", @@ -65,13 +66,15 @@ describe("route helpers", () => { writeRoute(route); expect(pushed).toEqual(["http://localhost/app?old=1&machine=remote&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat#section"]); + expect(replaced).toEqual([]); }); - it("does not push history when the route is unchanged", () => { - const { pushed } = installWindow("http://localhost/app?project=p1&tool=core%3Aworkspace.git"); + it("does not write history when the route is unchanged", () => { + const { pushed, replaced } = installWindow("http://localhost/app?project=p1&tool=core%3Aworkspace.git"); writeRoute({ machineId: undefined, projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined }); expect(pushed).toEqual([]); + expect(replaced).toEqual([]); }); }); diff --git a/src/client/src/theme.test.ts b/src/client/src/theme.test.ts index 25a70c9..a973c89 100644 --- a/src/client/src/theme.test.ts +++ b/src/client/src/theme.test.ts @@ -93,17 +93,21 @@ describe("resolveThemePreference", () => { expect(resolution.activeTheme?.id).toBe("themes:classic"); }); - it("does not overwrite a missing selected theme preference in the resolution result", () => { - const missingThemeId: QualifiedContributionId = "plugin:missing"; + it("falls back to Classic without mutating a missing selected theme preference", () => { + const preference = { + themeId: "plugin:missing", + auto: true, + } satisfies { themeId: QualifiedContributionId; auto: boolean }; const resolution = resolveThemePreference({ themes, themePairs, - preference: { themeId: missingThemeId, auto: true }, + preference, prefersLight: false, }); expect(resolution.selectedTheme?.id).toBe("themes:classic"); - expect(missingThemeId).toBe("plugin:missing"); + expect(resolution.activeTheme?.id).toBe("themes:classic"); + expect(preference).toEqual({ themeId: "plugin:missing", auto: true }); }); it("can look up a pair from either member theme", () => { diff --git a/src/client/src/workspaceActivity.test.ts b/src/client/src/workspaceActivity.test.ts index 379308c..88bb548 100644 --- a/src/client/src/workspaceActivity.test.ts +++ b/src/client/src/workspaceActivity.test.ts @@ -15,9 +15,11 @@ function activity(cwd: string, patch: Partial = {}): Workspac } describe("workspace activity aggregation", () => { - it("matches activity to workspace paths", () => { - const ws = workspace("p1", "/repo"); - expect(workspaceActivityFor(ws, { "/repo": activity("/repo") })?.hasSessionActivity).toBe(true); + it("matches activity to workspace paths rather than ids", () => { + const ws = { ...workspace("p1", "/repo"), id: "workspace-1" }; + const matched = activity("/repo"); + + expect(workspaceActivityFor(ws, { "/repo": matched, "workspace-1": activity("workspace-1") })).toEqual(matched); }); it("uses a terminal indicator only when there is no session activity", () => { diff --git a/src/client/src/workspaceDeletion.test.ts b/src/client/src/workspaceDeletion.test.ts index 5d449a0..63c4de0 100644 --- a/src/client/src/workspaceDeletion.test.ts +++ b/src/client/src/workspaceDeletion.test.ts @@ -47,10 +47,18 @@ describe("workspace deletion state", () => { }); }); - it("reports pending workspace deletions for disabling repeated actions", () => { - const state = { workspaceDeletionRuns: { w1: run("new", "w1", "2026-05-25T00:00:01.000Z", "running") } }; + it("reports only queued or running workspace deletions as pending", () => { + const state = { + workspaceDeletionRuns: { + w1: run("running", "w1", "2026-05-25T00:00:01.000Z", "running"), + w2: run("queued", "w2", "2026-05-25T00:00:02.000Z", "queued"), + w3: run("succeeded", "w3", "2026-05-25T00:00:03.000Z", "succeeded"), + w4: run("failed", "w4", "2026-05-25T00:00:04.000Z", "failed"), + }, + }; expect(isWorkspaceDeletionPending(state, workspace)).toBe(true); - expect(pendingWorkspaceDeletionIds(state.workspaceDeletionRuns)).toEqual(["w1"]); + expect(isWorkspaceDeletionPending(state, { ...workspace, id: "w3" })).toBe(false); + expect(pendingWorkspaceDeletionIds(state.workspaceDeletionRuns)).toEqual(["w1", "w2"]); }); }); diff --git a/src/config.test.ts b/src/config.test.ts index c23c0c2..617eb40 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -18,9 +18,23 @@ afterEach(async () => { describe("PI WEB config persistence", () => { it("writes and reads the configured PI WEB config path", () => { - const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual\\incoming" } }, testOptions()); + const requestedConfig = { + host: "0.0.0.0", + port: 9000, + allowedHosts: ["example.local"], + shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, + plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, + pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, + uploads: { defaultFolder: "manual\\incoming" }, + }; + const normalizedConfig = { + ...requestedConfig, + uploads: { defaultFolder: "manual/incoming" }, + }; - expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/incoming" } } }); + const saved = savePiWebConfig(requestedConfig, testOptions()); + + expect(saved).toEqual({ path: configPath, exists: true, config: normalizedConfig }); expect(loadPiWebConfig(testOptions())).toEqual(saved); }); diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts index bcca824..0bb60c1 100644 --- a/src/server/configRoutes.test.ts +++ b/src/server/configRoutes.test.ts @@ -35,15 +35,32 @@ describe("config routes", () => { }); it("updates config through the service", async () => { + const requestedConfig: PiWebConfigValues = { + host: "0.0.0.0", + port: 9000, + allowedHosts: true, + spawnSessions: true, + subsessions: true, + shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, + plugins: { info: { enabled: false, settings: { note: "hidden" } } }, + pathAccess: { allowedPaths: ["/tmp"] }, + uploads: { defaultFolder: "uploads\\manual" }, + maxUploadBytes: 1234, + }; + const expectedConfig: PiWebConfigValues = { + ...requestedConfig, + uploads: { defaultFolder: "uploads/manual" }, + }; + const response = await app.inject({ method: "PUT", url: "/api/config", - payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } }, + payload: { config: requestedConfig }, }); expect(response.statusCode).toBe(200); - expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 }); - expect(response.json().config).toEqual(savedConfig); + expect(savedConfig).toEqual(expectedConfig); + expect(response.json().config).toEqual(expectedConfig); }); it("rejects invalid config payloads before writing", async () => { @@ -109,11 +126,16 @@ describe("config routes", () => { it("merges local selected-machine config updates without dropping gateway-only keys", async () => { savedConfig = fullConfig(); + const selectedMachinePatch: PiWebConfigValues = { + plugins: { info: { enabled: false } }, + uploads: { defaultFolder: "uploads\\manual" }, + spawnSessions: true, + }; const response = await app.inject({ method: "PUT", url: "/api/machines/local/config", - payload: { config: { plugins: { info: { enabled: false } }, uploads: { defaultFolder: "uploads\\manual" }, spawnSessions: true } }, + payload: { config: selectedMachinePatch }, }); const expectedConfig: PiWebConfigValues = { diff --git a/src/server/dockerControlAssets.test.ts b/src/server/dockerControlAssets.test.ts index 00febfa..912bc18 100644 --- a/src/server/dockerControlAssets.test.ts +++ b/src/server/dockerControlAssets.test.ts @@ -536,7 +536,7 @@ async function withUnixSocket(socketPath: string, callback: () => Promise) function cleanProcessEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; for (const key of Object.keys(env)) { - if (key === "COMPOSE_PROJECT_NAME" || key === "DOCKER_GID" || key === "HOSTEXEC_IMAGE" || key.startsWith("PI_WEB_")) { + if (key === "COMPOSE_PROJECT_NAME" || key === "DOCKER_GID" || key === "HOSTEXEC_IMAGE" || key === "XDG_DATA_HOME" || key.startsWith("PI_WEB_")) { Reflect.deleteProperty(env, key); } } diff --git a/src/server/machines/machineService.test.ts b/src/server/machines/machineService.test.ts index 2e255bf..f0f8714 100644 --- a/src/server/machines/machineService.test.ts +++ b/src/server/machines/machineService.test.ts @@ -24,6 +24,7 @@ describe("MachineService", () => { expect(await service.list()).toEqual([ { id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" }, ]); + await expect(stat(storePath)).rejects.toMatchObject({ code: "ENOENT" }); }); it("adds remote machines and omits secrets from public responses", async () => { @@ -38,8 +39,7 @@ describe("MachineService", () => { await expectOwnerOnlyMachineStore(storePath); }); - it("tightens permissions after reading an existing machine store", async () => { - if (process.platform === "win32") return; + it.skipIf(process.platform === "win32")("tightens permissions after reading an existing machine store", async () => { await writeFile(storePath, `${JSON.stringify({ machines: [{ id: "remote-1", diff --git a/src/server/piPackageRoutes.test.ts b/src/server/piPackageRoutes.test.ts index a4cd553..18bbb20 100644 --- a/src/server/piPackageRoutes.test.ts +++ b/src/server/piPackageRoutes.test.ts @@ -85,9 +85,11 @@ describe("registerPiPackageRoutes", () => { expect(missingSource.statusCode).toBe(400); expect(missingSource.json()).toEqual({ error: "Pi package source must be a non-empty string" }); expect(blankSource.statusCode).toBe(400); + expect(blankSource.json()).toEqual({ error: "Pi package source must be a non-empty string" }); expect(invalidScope.statusCode).toBe(400); expect(invalidScope.json()).toEqual({ error: "Pi package scope must be \"user\" or \"project\"" }); expect(invalidUpdate.statusCode).toBe(400); + expect(invalidUpdate.json()).toEqual({ error: "Pi package source must be a non-empty string" }); expect(serviceMocks.install).not.toHaveBeenCalled(); expect(serviceMocks.remove).not.toHaveBeenCalled(); expect(serviceMocks.update).not.toHaveBeenCalled(); diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 352f164..517973c 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -172,19 +172,30 @@ describe("PiWebPluginService", () => { }); it("skips duplicate plugin ids", async () => { - await writePlugin(join(tempDir, "plugins", "one"), { - packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } }, - files: { "pi-web-plugin.js": "export default {};" }, + const firstRoot = join(tempDir, "first-root"); + const secondRoot = join(tempDir, "second-root"); + await writePlugin(join(firstRoot, "duplicate"), { + packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "first.js" }] } }, + files: { "first.js": "export default {};" }, }); - await writePlugin(join(tempDir, "plugins", "two"), { - packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } }, - files: { "pi-web-plugin.js": "export default {};" }, + await writePlugin(join(secondRoot, "duplicate"), { + packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "second.js", machineSpecific: true }] } }, + files: { "second.js": "export default {};" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + const service = new PiWebPluginService({ + roots: [ + { path: firstRoot, source: "first", scope: "local" }, + { path: secondRoot, source: "second", scope: "local" }, + ], + packageProvider: false, + }); const manifest = await service.manifest(); - expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate"]); + expect(manifest.plugins).toEqual([ + expect.objectContaining({ id: "duplicate", source: "first", machineSpecific: false }), + ]); + expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/duplicate\/first\.js\?v=\d+$/u); }); it("skips legacy metadata shortcuts and unsafe module paths", async () => { diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index e214e61..15cd1a5 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -90,8 +90,7 @@ describe("PI WEB status", () => { expect(status.messages.map((message) => message.id)).toContain("sessiond-stale"); }); - it("suggests native systemd commands for local development services", async () => { - if (process.platform !== "linux") return; + it.skipIf(process.platform !== "linux")("suggests native systemd commands for local development services", async () => { process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1"; disableDockerRuntimeEnv(); const home = await tempHome(); diff --git a/src/server/sessions/authProviderOptions.test.ts b/src/server/sessions/authProviderOptions.test.ts index 854bfab..e6fa31e 100644 --- a/src/server/sessions/authProviderOptions.test.ts +++ b/src/server/sessions/authProviderOptions.test.ts @@ -33,7 +33,7 @@ describe("auth provider options", () => { expect(isApiKeyLoginProvider("openai", new Set(["openai-codex"]))).toBe(true); }); - it("includes Anthropic in both OAuth and API key login options", () => { + it("builds login options for OAuth-only, dual-auth, and API-key providers", () => { const options = getLoginProviderOptions(registry()); expect(options).toEqual(expect.arrayContaining([ expect.objectContaining({ id: "anthropic", authType: "oauth" }), @@ -44,7 +44,7 @@ describe("auth provider options", () => { expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })])); }); - it("returns only stored credentials for logout", () => { + it("returns only currently stored credentials for logout", () => { expect(getLogoutProviderOptions(registry())).toEqual([ expect.objectContaining({ id: "openai", authType: "api_key" }), ]); diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index 55ddc82..f9b8f93 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -169,23 +169,6 @@ function emptyArchiveStore(): NonNullable { - it("exposes the session's agent.streamFn for one-off model calls", async () => { - const hub = new CapturingSessionEventHub(); - const streamFn = vi.fn(); - const fake = fakeRuntime("stream-session", { agent: { streamFn } }); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - expect(fake.session.agent.streamFn).toBe(streamFn); - - await service.dispose(); - }); - it("starts sessions through an injected runtime creator", async () => { const hub = new CapturingSessionEventHub(); const fake = fakeRuntime(); @@ -957,14 +940,21 @@ describe("PiSessionService", () => { it("rejects malformed prompt text before opening the runtime", async () => { const fake = fakeRuntime("prompt-session"); + let createCalls = 0; + const createAgentRuntime: RuntimeCreator = async () => { + createCalls += 1; + await Promise.resolve(); + return fake.runtime; + }; const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), + createAgentRuntime, sessionManager: sessionGateway([sessionRecord("prompt-session")]), heartbeatIntervalMs: 60_000, }); await expect(service.prompt("prompt-session", undefined)).rejects.toThrow("Prompt text is required"); + expect(createCalls).toBe(0); expect(fake.calls.prompt).toEqual([]); await service.dispose(); }); @@ -1313,7 +1303,7 @@ describe("PiSessionService", () => { } it("records the parent, delivers the prompt, and lists the tracked child", async () => { - const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + const { child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); await service.start("/workspace"); // bring the parent online so it can be notified const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" }); @@ -1323,7 +1313,6 @@ describe("PiSessionService", () => { await expect(service.listSubsessions("parent-1")).resolves.toEqual([ { sessionId: "child-1", cwd: "/workspace-feature", status: "idle" }, ]); - void parent; await service.dispose(); }); diff --git a/src/server/sessions/sessionCommandService.test.ts b/src/server/sessions/sessionCommandService.test.ts index 7ca503c..9aa3acb 100644 --- a/src/server/sessions/sessionCommandService.test.ts +++ b/src/server/sessions/sessionCommandService.test.ts @@ -66,11 +66,15 @@ describe("SessionCommandService", () => { await expect(service.run("s1", "/template arg")).resolves.toMatchObject({ type: "done" }); await expect(service.run("s1", "/skill:skill-a arg")).resolves.toMatchObject({ type: "done" }); expect(prompt).toHaveBeenCalledTimes(3); + expect(prompt).toHaveBeenNthCalledWith(1, "s1", "/ext arg"); + expect(prompt).toHaveBeenNthCalledWith(2, "s1", "/template arg"); + expect(prompt).toHaveBeenNthCalledWith(3, "s1", "/skill:skill-a arg"); }); - it("renames sessions and returns updated client session metadata", async () => { + it("renames sessions, publishes the name update, and returns updated client session metadata", async () => { const active = activeSession(); - const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher()); + const events = eventPublisher(); + const service = new SessionCommandService(() => getActive(active), vi.fn(), events); await expect(service.run("s1", "/name Useful name")).resolves.toMatchObject({ type: "done", @@ -78,6 +82,7 @@ describe("SessionCommandService", () => { session: { id: "s1", cwd: "/work", name: "Useful name", messageCount: 2 }, }); expect(active.runtime.session.setSessionName).toHaveBeenCalledWith("Useful name"); + expect(events.publish).toHaveBeenCalledWith("s1", { type: "session.name", sessionId: "s1", name: "Useful name" }); }); it("formats session stats", async () => { @@ -90,18 +95,22 @@ describe("SessionCommandService", () => { }); }); - it("starts compaction and publishes completion", async () => { + it("starts compaction, updates lifecycle hooks, and publishes completion", async () => { const active = activeSession(); const events = eventPublisher(); - const service = new SessionCommandService(() => getActive(active), vi.fn(), events); + const onCompactionStart = vi.fn(); + const onCompactionEnd = vi.fn(); + const service = new SessionCommandService(() => getActive(active), vi.fn(), events, { onCompactionStart, onCompactionEnd }); await expect(service.run("s1", "/compact focus on tests")).resolves.toEqual({ type: "done", message: "Compaction started…" }); + expect(onCompactionStart).toHaveBeenCalledWith(active.runtime.session); await vi.waitFor(() => { expect(events.publish).toHaveBeenCalledWith("s1", { type: "command.output", level: "success", message: "Compaction complete.\nTokens before: 123\n\nshort summary", }); + expect(onCompactionEnd).toHaveBeenCalledWith(active.runtime.session, "success"); }); expect(active.runtime.session.compact).toHaveBeenCalledWith("focus on tests"); }); diff --git a/src/server/sessions/spawnSessionTool.test.ts b/src/server/sessions/spawnSessionTool.test.ts index fbc8c95..ea19549 100644 --- a/src/server/sessions/spawnSessionTool.test.ts +++ b/src/server/sessions/spawnSessionTool.test.ts @@ -9,7 +9,7 @@ const dispatchModel = { provider: "anthropic", id: "claude-sonnet" }; const ctxWithModel = { model: dispatchModel } as ExtensionContext; describe("createSpawnSessionToolDefinition", () => { - it("passes the spawning cwd and params to the spawn callback and reports success", async () => { + it("passes the spawning cwd, explicit cwd, dispatching model, and prompt to spawn callback", async () => { const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" })); const tool = createSpawnSessionToolDefinition("/repos/a", { spawn }); @@ -20,7 +20,7 @@ describe("createSpawnSessionToolDefinition", () => { expect(result.content[0]).toMatchObject({ type: "text", text: "Started session new-1 in /repos/a-feature." }); }); - it("defaults cwd to undefined so the service falls back to the spawning cwd", async () => { + it("forwards omitted cwd as undefined and omits a missing dispatching model", async () => { const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-2", cwd: "/repos/a" })); const tool = createSpawnSessionToolDefinition("/repos/a", { spawn }); @@ -29,15 +29,6 @@ describe("createSpawnSessionToolDefinition", () => { expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined }); }); - it("omits the inherited model when the dispatching session has no current model", async () => { - const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-3", cwd: "/repos/a" })); - const tool = createSpawnSessionToolDefinition("/repos/a", { spawn }); - - await tool.execute("call-3", { prompt: "continue" }, undefined, undefined, ctx); - - expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined }); - }); - it("propagates the spawn callback error so the agent loop reports it", async () => { const spawn = vi.fn(() => Promise.reject(new Error("cwd must be a workspace of this project. Allowed: /repos/a"))); const tool = createSpawnSessionToolDefinition("/repos/a", { spawn }); diff --git a/src/server/sessions/subsessionTranscript.test.ts b/src/server/sessions/subsessionTranscript.test.ts index 4821740..56ef8c2 100644 --- a/src/server/sessions/subsessionTranscript.test.ts +++ b/src/server/sessions/subsessionTranscript.test.ts @@ -81,12 +81,12 @@ describe("buildTranscriptView", () => { expect(callPart.args).toEqual({ command: "ls" }); }); - it("search keeps only matching entries across text and tool names", () => { - const messages = [assistant("the auth flow"), assistant("unrelated"), toolResult("error in auth.ts", "read")]; + it("search keeps only entries matching text or tool-call names", () => { + const messages = [assistant("the auth flow"), assistant("unrelated"), toolResult("error in auth.ts", "read"), toolCall("auth-search")]; const view = buildTranscriptView(messages, { search: "auth" }); - expect(view.matched).toBe(2); - expect(view.entries.map((entry) => entry.index)).toEqual([0, 2]); + expect(view.matched).toBe(3); + expect(view.entries.map((entry) => entry.index)).toEqual([0, 2, 3]); }); it("search runs against full content even when maxChars would clip the match away", () => { diff --git a/src/server/terminals/terminalRoutes.test.ts b/src/server/terminals/terminalRoutes.test.ts index 815d1ee..47bc2b3 100644 --- a/src/server/terminals/terminalRoutes.test.ts +++ b/src/server/terminals/terminalRoutes.test.ts @@ -43,7 +43,7 @@ describe("terminal routes", () => { expect(terminals.events).toEqual([`close-cwd:${requestCwd}`]); }); - it("creates and lists terminal command runs with filters", async () => { + it("routes command-run create, filter, cancel, and terminal continue requests", async () => { const createResponse = await app.inject({ method: "POST", url: "/terminal-command-runs", diff --git a/src/server/workingDirectory.test.ts b/src/server/workingDirectory.test.ts index f542d08..b55f9e9 100644 --- a/src/server/workingDirectory.test.ts +++ b/src/server/workingDirectory.test.ts @@ -13,8 +13,7 @@ describe("normalizeRequestCwd", () => { expect(normalizeRequestCwd(join(absoluteBase, ".", "nested", ".."))).toBe(absoluteBase); }); - it("treats Windows backslash and forward-slash paths as equal", () => { - if (process.platform !== "win32") return; + it.skipIf(process.platform !== "win32")("treats Windows backslash and forward-slash paths as equal", () => { expect(normalizeRequestCwd("C:/Users/dev/project")).toBe("C:\\Users\\dev\\project"); }); @@ -47,8 +46,7 @@ describe("cwdPathsEqual", () => { expect(cwdPathsEqual(absoluteBase, join(absoluteBase, "."))).toBe(true); }); - it("treats Windows backslash and forward-slash paths as equal", () => { - if (process.platform !== "win32") return; + it.skipIf(process.platform !== "win32")("treats Windows backslash and forward-slash paths as equal", () => { expect(cwdPathsEqual("C:\\Users\\dev\\project", "C:/Users/dev/project")).toBe(true); }); diff --git a/src/server/workspaces/fileContentService.test.ts b/src/server/workspaces/fileContentService.test.ts index c99a1a3..e2ab634 100644 --- a/src/server/workspaces/fileContentService.test.ts +++ b/src/server/workspaces/fileContentService.test.ts @@ -130,13 +130,14 @@ describe("writeWorkspaceFile", () => { expect(content).toBe("const greeting = 'hello';\n"); }); - it("writes binary content", async () => { + it("writes binary content without text re-encoding", async () => { const root = await tempWorkspace(); const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); const result = await writeWorkspaceFile(root, "image.png", binaryData); expect(result).toMatchObject({ path: "image.png", created: true, size: 6 }); + await expect(readFile(join(root, "image.png"))).resolves.toEqual(binaryData); }); it("overwrites existing files by default", async () => { @@ -190,14 +191,12 @@ describe("writeWorkspaceFile", () => { it("prevents writing through symlinks that escape the workspace", async () => { const root = await tempWorkspace(); await mkdir(join(root, "subdir"), { recursive: true }); - // Create a symlink inside the workspace that points outside - const { symlink } = await import("node:fs/promises"); const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-")); roots.push(outsideDir); await symlink(outsideDir, join(root, "subdir", "escape"), "junction"); - // Attempting to write through the symlink should be blocked - await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow(); + await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow("Path escapes workspace"); + await expect(readFile(join(outsideDir, "evil.txt"))).rejects.toMatchObject({ code: "ENOENT" }); }); }); @@ -227,7 +226,7 @@ describe("deleteWorkspaceFile", () => { await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory"); }); - it("rejects path traversal", async () => { + it("rejects traversal and absolute paths", async () => { const root = await tempWorkspace(); await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed"); @@ -253,7 +252,7 @@ describe("deleteWorkspaceFile", () => { expect(result).toMatchObject({ path: "link.txt", existed: true }); // The symlink should be gone, but the target file should still exist - await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow(); + await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow("Path does not exist"); const realContent = await readFile(join(outsideDir, "real.txt"), "utf8"); expect(realContent).toBe("real content"); }); @@ -307,6 +306,8 @@ describe("moveWorkspaceFile", () => { await writeFile(join(root, "file.txt"), "data"); await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow(); + const source = await readWorkspaceFile(root, "file.txt"); + expect(source.content).toBe("data"); }); it("overwrites target when overwrite is true", async () => { @@ -327,9 +328,11 @@ describe("moveWorkspaceFile", () => { await writeFile(join(root, "target.txt"), "target"); await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists"); - // Source should still exist + // Source and target should remain unchanged const source = await readWorkspaceFile(root, "source.txt"); expect(source.content).toBe("source"); + const target = await readWorkspaceFile(root, "target.txt"); + expect(target.content).toBe("target"); }); it("rejects source path traversal", async () => { @@ -342,7 +345,9 @@ describe("moveWorkspaceFile", () => { const root = await tempWorkspace(); await writeFile(join(root, "source.txt"), "data"); - await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow(); + await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow("Path traversal is not allowed"); + const source = await readWorkspaceFile(root, "source.txt"); + expect(source.content).toBe("data"); }); it("rejects moving a directory", async () => { @@ -370,6 +375,9 @@ describe("moveWorkspaceFile", () => { roots.push(outsideDir); await symlink(outsideDir, join(root, "subdir", "escape"), "junction"); - await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow(); + await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow("Path escapes workspace"); + const source = await readWorkspaceFile(root, "subdir/file.txt"); + expect(source.content).toBe("data"); + await expect(readFile(join(outsideDir, "evil.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); }); }); diff --git a/src/server/workspaces/workspaceDeletionRoutes.test.ts b/src/server/workspaces/workspaceDeletionRoutes.test.ts index 4ff152d..8605c32 100644 --- a/src/server/workspaces/workspaceDeletionRoutes.test.ts +++ b/src/server/workspaces/workspaceDeletionRoutes.test.ts @@ -53,7 +53,7 @@ afterEach(async () => { }); describe("workspace deletion routes", () => { - it("closes target workspace terminals before starting the deletion terminal command", async () => { + it("closes target workspace terminals before starting deletion from the main workspace", async () => { const response = await app.inject({ method: "DELETE", url: "/api/projects/p1/workspaces/feature" }); expect(response.statusCode).toBe(200); diff --git a/src/shared/piWebStatusParsing.test.ts b/src/shared/piWebStatusParsing.test.ts index adb2c75..dbc7389 100644 --- a/src/shared/piWebStatusParsing.test.ts +++ b/src/shared/piWebStatusParsing.test.ts @@ -3,7 +3,7 @@ import { PI_WEB_CAPABILITIES } from "./capabilities"; import { parsePiWebComponentStatus, parsePiWebInstallationInfo, parsePiWebRuntimeResponse, parsePiWebVersionResponse } from "./piWebStatusParsing"; describe("PI WEB status parsing", () => { - it("parses known runtime capabilities and ignores unknown string capabilities", () => { + it("parses known top-level and component capabilities while ignoring unknown strings", () => { expect(parsePiWebRuntimeResponse({ packageName: "@jmfederico/pi-web", generatedAt: "now", @@ -21,7 +21,7 @@ describe("PI WEB status parsing", () => { }); }); - it("rejects malformed capability arrays", () => { + it("rejects runtime responses with malformed component capability arrays", () => { expect(parsePiWebRuntimeResponse({ packageName: "@jmfederico/pi-web", generatedAt: "now", diff --git a/src/shared/promptAttachments.test.ts b/src/shared/promptAttachments.test.ts index 28ae518..49265f6 100644 --- a/src/shared/promptAttachments.test.ts +++ b/src/shared/promptAttachments.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { base64ByteLength, extensionForImageMimeType, isSupportedImageMimeType, MAX_INLINE_IMAGE_BASE64_BYTES, parsePromptAttachments } from "./promptAttachments.js"; -const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCA',".replace(/[^A-Za-z0-9+/=]/g, ""); +const validImageBase64 = "QUJD"; describe("isSupportedImageMimeType", () => { it("accepts pi-supported image types", () => { @@ -43,12 +43,12 @@ describe("parsePromptAttachments", () => { }); it("normalizes valid attachments", () => { - const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: tinyPngBase64, name: "shot.png" }]); - expect(result).toEqual([{ kind: "image", mimeType: "image/png", data: tinyPngBase64, name: "shot.png" }]); + const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: validImageBase64, name: "shot.png" }]); + expect(result).toEqual([{ kind: "image", mimeType: "image/png", data: validImageBase64, name: "shot.png" }]); }); it("drops empty names", () => { - const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: tinyPngBase64, name: "" }]); + const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: validImageBase64, name: "" }]); expect(result[0]).not.toHaveProperty("name"); }); @@ -57,9 +57,9 @@ describe("parsePromptAttachments", () => { }); it("rejects unsupported kinds and mime types", () => { - expect(() => parsePromptAttachments([{ kind: "video", mimeType: "image/png", data: tinyPngBase64 }])).toThrow(/unsupported kind/); - expect(() => parsePromptAttachments([{ kind: "file", mimeType: "application/pdf", data: tinyPngBase64 }])).toThrow(/unsupported kind/); - expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }])).toThrow(/unsupported image type/); + expect(() => parsePromptAttachments([{ kind: "video", mimeType: "image/png", data: validImageBase64 }])).toThrow(/unsupported kind/); + expect(() => parsePromptAttachments([{ kind: "file", mimeType: "application/pdf", data: validImageBase64 }])).toThrow(/unsupported kind/); + expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: validImageBase64 }])).toThrow(/unsupported image type/); }); it("accepts generic files only when file attachments are allowed", () => { @@ -83,7 +83,7 @@ describe("parsePromptAttachments", () => { }); it("keeps image MIME validation when file attachments are allowed", () => { - expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }], { allowFileAttachments: true })).toThrow(/unsupported image type/); + expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: validImageBase64 }], { allowFileAttachments: true })).toThrow(/unsupported image type/); }); it("rejects invalid base64 data", () => { @@ -97,7 +97,7 @@ describe("parsePromptAttachments", () => { }); it("enforces the attachment count limit", () => { - const many = Array.from({ length: 3 }, () => ({ kind: "image", mimeType: "image/png", data: tinyPngBase64 })); + const many = Array.from({ length: 3 }, () => ({ kind: "image", mimeType: "image/png", data: validImageBase64 })); expect(() => parsePromptAttachments(many, { maxAttachments: 2 })).toThrow(/too many attachments/); }); }); From d2b55207858fd3652d2ee9230df08bf75f633e03 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 3 Jul 2026 10:36:59 +0200 Subject: [PATCH 048/111] docs(relay): include leg identity in handoffs --- skills/relay/SKILL.md | 19 +++++++++++-------- skills/relay/evals/evals.json | 10 +++++----- skills/relay/evals/live-behavior-testing.md | 5 ++++- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/skills/relay/SKILL.md b/skills/relay/SKILL.md index d10d7c4..f043205 100644 --- a/skills/relay/SKILL.md +++ b/skills/relay/SKILL.md @@ -32,7 +32,7 @@ Every relay has these three core files: - **Goal / finish line.** A concrete, achievable end state. Without this the relay runs forever — this is non-negotiable. - **Sizing.** How much is *one leg*? This is project- and plan-specific; the charter defines it (a task, a slice, a time/scope budget — whatever fits). The skill does not decide this for you. - **Task selection policy.** How a runner chooses the next task when `status.md` does not name one explicitly. -- **Handover.** How a runner hands off: what the spawn prompt should say and what the next runner must read. A normal handoff points at `charter.md` and `status.md`, not the full log. +- **Handover.** How a runner hands off: what the spawn prompt should say and what the next runner must read. A normal handoff starts with a natural header containing the relay name and next leg number, then points at `charter.md` and `status.md`, not the full log. - **Intervention signal.** When and how a runner must stop and get the human, and how that is made visible. The charter must define this; the skill does not define it for you. - **Reading discipline.** The files a runner should read to orient, and any files that should not be read defensively. @@ -42,11 +42,12 @@ The charter *can* be edited, but it should rarely *need* to be. If it is changin - **Current position.** Where the relay is now. - **Current or next task.** The next leg if known; otherwise enough information to apply the charter's task selection policy. +- **Leg tracking.** The last completed leg and the next leg to run. Keep this explicit so runners do not have to infer whether “current leg” means the leg just finished or the leg being handed off, and so new PI-WEB sessions can distinguish relay legs from the first line of their prompt without naming instructions. - **Relevant context.** Only the files, sections, commands, artifacts, or specific log entries needed for the next leg. - **Progress documentation.** Where this runner must write progress: update `status.md`, append `log.md`, update artifacts, commit, etc. - **Blockers / intervention state.** Current risks, open decisions, or active reasons to stop. -Think of `status.md` as the thing passed from runner to runner. If it grows into a history dump, compress it back into current state plus pointers. +Think of `status.md` as the thing passed from runner to runner. If it grows into a history dump, compress it back into current state plus pointers. If an older relay lacks leg tracking, repair it when you update status; prefer the leg number from the prompt or status, and do not read `log.md` end-to-end just to count prior legs. **Log** (`log.md`) — append-only history. Each leg appends a concise entry recording what it did, decisions made and why, durable artifacts changed, status updates made, and blockers. The log preserves auditability, but it is **not** orientation memory. @@ -70,19 +71,21 @@ If `status.md` is insufficient, fix the baton rather than compensating by readin This is the loop you run when you are dispatched into a relay. -1. **Orient from the packet.** Read `charter.md` and `status.md`. Confirm the relay name/root, goal, sizing, handoff protocol, intervention signal, and current/next task. If you are not sure you are in a relay, the prompt or `.pi-web/relays/` is your clue — and reading this skill means you are. +1. **Orient from the packet.** Read `charter.md` and `status.md`. Confirm the relay name/root, goal, sizing, handoff protocol, last completed leg, next leg to run, intervention signal, and current/next task. If you are not sure you are in a relay, the prompt or `.pi-web/relays/` is your clue — and reading this skill means you are. 2. **Choose the leg.** Prefer the explicit current/next task in `status.md`. If none is named, apply the charter's task selection policy. If that still requires context, inspect only the referenced plan/backlog/artifact sections. If the next task is still ambiguous or would materially change direction, stop and involve the human. 3. **Re-anchor to the goal.** Does the goal still make sense given the status and what you now see? If reality has diverged from the charter, that is often an intervention moment — don't quietly redefine the task. 4. **Run one leg.** Do exactly one well-sized slice, per the charter's sizing. Resist doing "just a bit more" — extra scope bloats context and breaks the containment that makes Relay work. -5. **Document progress.** Make all work durable. Update `status.md` with the new current state, next task or task-selection pointer, relevant context for the next runner, and blockers. Append a concise `log.md` entry with what you did, why, decisions made, artifacts changed, and whether you are handing off or stopping. +5. **Document progress.** Make all work durable. Update `status.md` with the new current state, last completed leg, next leg to run (if any), next task or task-selection pointer, relevant context for the next runner, and blockers. Append a concise `log.md` entry with what you did, why, decisions made, artifacts changed, and whether you are handing off or stopping. 6. **Decide: hand off, or stop.** - - **Hand off** if there is a clear next leg and you are on track. Use `spawn_session` once, with a prompt that names the Relay method and points the next runner at `charter.md` and `status.md` (so this skill loads and they can orient cheaply). Then you are done. Handoff is deliberately fire-and-forget: `spawn_session` starts an independent session you will not see and cannot steer — do not reach for a tracked subsession to keep an eye on it. Letting go is the point. The next runner is trusted to run their own leg, and the relay packet is the only thread between you; if you feel the need to watch downstream work, that usually means the leg wasn't sized or handed off cleanly, or an intervention signal should have fired. + - **Hand off** if there is a clear next leg and you are on track. Use `spawn_session` once, with a prompt whose first line is a natural task header containing the relay name and next leg number (for example, `Relay "" leg begins now.`), followed by the Relay method and pointers to `charter.md` and `status.md` (so this skill loads and they can orient cheaply). Then you are done. Handoff is deliberately fire-and-forget: `spawn_session` starts an independent session you will not see and cannot steer — do not reach for a tracked subsession to keep an eye on it. Letting go is the point. The next runner is trusted to run their own leg, and the relay packet is the only thread between you; if you feel the need to watch downstream work, that usually means the leg wasn't sized or handed off cleanly, or an intervention signal should have fired. - **Stop — do not spawn —** if the goal is reached, or you are blocked, or the charter's intervention signal fires. Update `status.md`, append a clear note in `log.md`, and raise the intervention signal so the watching human sees exactly what happened and what they need to decide. A stalled relay that stopped cleanly with a clear blocker is a success; a relay that spawned a confused next runner is a failure. -A good handoff prompt is short and explicit: +A good handoff prompt is short and explicit. Put the relay identity and leg number at the very beginning so PI-WEB's session title generator sees useful distinguishing context without any naming instruction: ```text -You are continuing Relay "". +Relay "" leg begins now. + +You are the next runner in this Relay method chain. Read: - .pi-web/relays//charter.md @@ -95,7 +98,7 @@ Run one leg according to the charter. Before handing off, update status.md, appe ## Planning a relay -When the user asks to set up a relay, your job is to produce the relay packet: `charter.md`, `status.md`, and `log.md`. The charter must have the required slots filled: relay identity, goal, sizing, task selection policy, handover, intervention signal, and reading discipline. The initial status must give the first runner a compact baton: current position, first task or task selection pointer, relevant context, documentation expectations, and known blockers. The log may start empty or with a short seed entry explaining that the relay was created. +When the user asks to set up a relay, your job is to produce the relay packet: `charter.md`, `status.md`, and `log.md`. The charter must have the required slots filled: relay identity, goal, sizing, task selection policy, handover, intervention signal, and reading discipline. The initial status must give the first runner a compact baton: current position, leg tracking (usually last completed leg 0 and next leg to run 1 for a new relay), first task or task selection pointer, relevant context, documentation expectations, and known blockers. The log may start empty or with a short seed entry explaining that the relay was created. Draw the required choices out from the user rather than inventing them: ask what the finish line is, how much should be one leg, how runners pick tasks, how runners hand off, what they should read, and when they must stop and get the human. Sizing, task selection, and the intervention signal especially are the user's to decide — propose options if it helps them think, but do not quietly settle them yourself. diff --git a/skills/relay/evals/evals.json b/skills/relay/evals/evals.json index 5214ded..44b04e8 100644 --- a/skills/relay/evals/evals.json +++ b/skills/relay/evals/evals.json @@ -6,17 +6,17 @@ "id": 0, "name": "plan-a-relay", "prompt": "I want to migrate all our REST endpoints to the new validation layer — there are around 40 of them across src/server/routes. I won't be able to babysit this. Set it up as a relay so an agent can grind through it across sessions and only pull me in when it actually needs me.", - "expected_output": "Produces a relay packet (default .pi-web/relays//) with charter.md, status.md, and log.md. The charter has all required slots present: relay identity/root, goal/finish-line, sizing, task selection policy, handover, intervention signal, and reading discipline. The initial status is a compact baton with current position, first task or task-selection pointer, relevant context, progress documentation expectations, and known blockers. The agent asks the user to make sizing, task selection, reading discipline, and the intervention signal concrete rather than inventing strict rules. It does not prescribe what a 'good' leg size or cadence is. It may dispatch the first leg only after the packet is agreed.", + "expected_output": "Produces a relay packet (default .pi-web/relays//) with charter.md, status.md, and log.md. The charter has all required slots present: relay identity/root, goal/finish-line, sizing, task selection policy, handover, intervention signal, and reading discipline. The handover guidance says the spawn prompt starts with the relay name and leg number before pointing at charter.md and status.md. The initial status is a compact baton with current position, leg tracking for the last completed leg and next leg to run, first task or task-selection pointer, relevant context, progress documentation expectations, and known blockers. The agent asks the user to make sizing, task selection, reading discipline, and the intervention signal concrete rather than inventing strict rules. It does not prescribe what a 'good' leg size or cadence is. It may dispatch the first leg only after the packet is agreed.", "files": [], "assertions": [ { "name": "packet-created", "text": "A relay packet is created with charter.md, status.md, and log.md under the relay location (default .pi-web/relays// unless specified).", "type": "script" }, { "name": "goal-slot-present", "text": "The charter defines a concrete, achievable finish line / goal.", "type": "judgment" }, { "name": "sizing-slot-present", "text": "The charter states how much work is one leg (sizing), rather than leaving it undefined.", "type": "judgment" }, { "name": "task-selection-slot-present", "text": "The charter states how a runner chooses the next task when status.md does not name one explicitly.", "type": "judgment" }, - { "name": "handover-slot-present", "text": "The charter states the handover mechanism, including that the next runner reads charter.md and status.md.", "type": "judgment" }, + { "name": "handover-slot-present", "text": "The charter states the handover mechanism, including that the handoff prompt starts with the relay name and next leg number and that the next runner reads charter.md and status.md.", "type": "judgment" }, { "name": "intervention-slot-present", "text": "The charter defines an intervention signal: when/how a runner stops and gets the human.", "type": "judgment" }, { "name": "reading-discipline-present", "text": "The charter states the reading discipline, including not reading log.md end-to-end by default.", "type": "judgment" }, - { "name": "status-seeded", "text": "status.md is seeded as a compact baton with current position, first task or task-selection pointer, relevant context, documentation expectations, and known blockers.", "type": "judgment" }, + { "name": "status-seeded", "text": "status.md is seeded as a compact baton with current position, leg tracking for the last completed leg and next leg to run, first task or task-selection pointer, relevant context, documentation expectations, and known blockers.", "type": "judgment" }, { "name": "asks-not-prescribes", "text": "For sizing, task selection, reading discipline, and the intervention signal, the agent asks the user to make them concrete instead of imposing its own strict rules/cadence.", "type": "judgment" }, { "name": "no-premature-spawn", "text": "The agent does not spawn the first leg before the relay packet is agreed with the user.", "type": "script" } ] @@ -25,7 +25,7 @@ "id": 1, "name": "run-one-leg-and-hand-off", "prompt": "You're working under the Relay framework. Read .pi-web/relays//charter.md and .pi-web/relays//status.md, continue the plan, then dispatch the next agent.", - "expected_output": "Loads the relay skill (handoff prompt names the framework). Orients by reading charter.md and status.md, not the full log. Re-anchors to the goal, chooses the next task from status.md or the charter's task-selection policy, does exactly ONE well-sized leg per the charter's sizing, updates status.md as a compact baton, appends a concise log.md entry, makes work durable (saves files, commits if the charter calls for it), then calls spawn_session exactly once with a handoff prompt that names Relay and points at charter.md and status.md. Does not do extra legs, does not spawn more than once, and does not tell the next runner to read log.md end-to-end.", + "expected_output": "Loads the relay skill (handoff prompt names the framework). Orients by reading charter.md and status.md, not the full log. Re-anchors to the goal, chooses the next task from status.md or the charter's task-selection policy, does exactly ONE well-sized leg per the charter's sizing, updates status.md as a compact baton, appends a concise log.md entry, makes work durable (saves files, commits if the charter calls for it), then calls spawn_session exactly once with a handoff prompt that starts with the relay name and next leg number, names Relay, and points at charter.md and status.md. Does not do extra legs, does not spawn more than once, and does not tell the next runner to read log.md end-to-end.", "files": [], "assertions": [ { "name": "skill-loads-from-handoff", "text": "The agent recognizes it is in a relay and loads/consults the relay skill from the handoff prompt.", "type": "judgment" }, @@ -37,7 +37,7 @@ { "name": "log-appended", "text": "A concise log.md entry is appended recording what was done, decisions, artifacts changed, status updates made, and any blocker.", "type": "script" }, { "name": "work-durable-before-handoff", "text": "Work is saved (and committed if the charter requires it) before spawn_session is called.", "type": "script" }, { "name": "spawn-exactly-once", "text": "spawn_session is called exactly once.", "type": "script" }, - { "name": "handoff-names-relay-and-status", "text": "The spawn prompt names the Relay framework and points the next runner at charter.md and status.md, not the full log, so the skill loads downstream with bounded context.", "type": "judgment" } + { "name": "handoff-names-relay-leg-and-status", "text": "The spawn prompt starts with the relay name and next leg number, names the Relay framework, and points the next runner at charter.md and status.md, not the full log, so the skill loads downstream with bounded context and PI-WEB can generate a distinguishable session name.", "type": "judgment" } ] }, { diff --git a/skills/relay/evals/live-behavior-testing.md b/skills/relay/evals/live-behavior-testing.md index b23e799..586f52c 100644 --- a/skills/relay/evals/live-behavior-testing.md +++ b/skills/relay/evals/live-behavior-testing.md @@ -75,7 +75,10 @@ So when testing or running a relay whose packet lives outside the repo, keep `cw spawn_session cwd: Prompt: -You are continuing Relay "sandbox". +Relay "sandbox" leg 2 begins now. + +You are the next runner in this Relay method chain. + Read: - /tmp/pi-web-relay-live-evals/.../sandbox/.pi-web/relays/sandbox/charter.md - /tmp/pi-web-relay-live-evals/.../sandbox/.pi-web/relays/sandbox/status.md From 10efb7f221acce995c2e888cb53eb2e92bbc5d86 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 3 Jul 2026 10:51:45 +0200 Subject: [PATCH 049/111] fix(sessions): name relay handoffs deterministically --- .changeset/relay-session-names.md | 5 +++ src/server/sessions/piSessionService.ts | 9 +++++- .../sessions/sessionNameGenerator.test.ts | 17 +++++++++- src/server/sessions/sessionNameGenerator.ts | 32 +++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 .changeset/relay-session-names.md diff --git a/.changeset/relay-session-names.md b/.changeset/relay-session-names.md new file mode 100644 index 0000000..0d0275a --- /dev/null +++ b/.changeset/relay-session-names.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Name Relay handoff sessions deterministically from their relay name and leg number. diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 9d59b66..fa5f783 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -24,7 +24,7 @@ import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInp import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js"; import type { ActiveSession } from "./sessionRuntimeStore.js"; import type { AuthChange } from "./authService.js"; -import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js"; +import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js"; import { computeEditPreview, type EditPreviewResult } from "./editPreview.js"; import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js"; import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js"; @@ -1706,6 +1706,13 @@ export class PiSessionService { private maybeGenerateSessionName(session: PiAgentSession, firstMessage: string): void { if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting) return; + + const deterministicName = deterministicSessionName(firstMessage); + if (deterministicName !== undefined) { + this.applyGeneratedSessionName(session, deterministicName); + return; + } + const model = session.model; if (model === undefined) return; diff --git a/src/server/sessions/sessionNameGenerator.test.ts b/src/server/sessions/sessionNameGenerator.test.ts index 12078cb..01296ca 100644 --- a/src/server/sessions/sessionNameGenerator.test.ts +++ b/src/server/sessions/sessionNameGenerator.test.ts @@ -2,7 +2,7 @@ import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai"; import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; import type { StreamFn } from "@earendil-works/pi-agent-core"; import { describe, expect, it } from "vitest"; -import { cleanSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js"; +import { cleanSessionName, deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js"; function fakeModel(): Model { return { id: "fake-model", name: "Fake Model", api: "anthropic-messages", provider: "anthropic", baseUrl: "https://example.test", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 100 }; @@ -69,6 +69,21 @@ describe("sessionNameGenerator", () => { expect(cleanSessionName('Title: "Fix Session Naming."\nextra')).toBe("Fix Session Naming"); }); + it("builds deterministic names for relay handoff prompts", () => { + expect(deterministicSessionName('Relay "handoff-check" leg 2 begins now.\n\nYou are the next runner.')) + .toBe("Relay handoff-check leg 2"); + }); + + it("preserves the relay leg when truncating deterministic relay names", () => { + expect(deterministicSessionName('Relay "very-long-relay-name-that-would-otherwise-push-the-leg-number-out-of-view" leg 42 begins now.')) + .toBe("Relay very-long-relay-name-that-would-otherwise-push leg 42"); + }); + + it("does not build deterministic names for non-canonical relay prompts", () => { + expect(deterministicSessionName('You are continuing Relay "handoff-check" under the Relay method.')) + .toBeUndefined(); + }); + it("builds a concise fallback from the first request", () => { expect(fallbackSessionName("Seems like auto name for sessions is not working, I still get the first message as a name.")) .toBe("Seems like auto name for sessions"); diff --git a/src/server/sessions/sessionNameGenerator.ts b/src/server/sessions/sessionNameGenerator.ts index b95a5d7..f0bf573 100644 --- a/src/server/sessions/sessionNameGenerator.ts +++ b/src/server/sessions/sessionNameGenerator.ts @@ -5,6 +5,13 @@ const SESSION_NAME_TIMEOUT_MS = 10_000; const SESSION_NAME_MAX_INPUT_CHARS = 4_000; const SESSION_NAME_MAX_LENGTH = 60; const FALLBACK_SESSION_NAME_MAX_WORDS = 6; +const RELAY_HANDOFF_FIRST_LINE = /^Relay\s+"([^"\n]+)"\s+leg\s+(\d+)\s+begins now\.?\s*(?:\n|$)/; + +export function deterministicSessionName(firstMessage: unknown): string | undefined { + if (typeof firstMessage !== "string") return undefined; + + return relayHandoffSessionName(firstMessage.trimStart()); +} export async function generateShortSessionName(streamFn: StreamFn, model: Model, firstMessage: string): Promise { const stream = await streamFn( @@ -59,6 +66,31 @@ export function cleanSessionName(value: string): string | undefined { return title === "" ? undefined : title; } +function relayHandoffSessionName(firstMessage: string): string | undefined { + const match = RELAY_HANDOFF_FIRST_LINE.exec(firstMessage); + if (match === null) return undefined; + + const relayName = match[1]?.replace(/\s+/g, " ").trim(); + const legNumber = match[2]; + if (relayName === undefined || relayName === "" || legNumber === undefined) return undefined; + + return cleanSessionName(formatRelaySessionName(relayName, legNumber)); +} + +function formatRelaySessionName(relayName: string, legNumber: string): string { + const prefix = "Relay "; + const suffix = ` leg ${legNumber}`; + const maxRelayNameLength = Math.max(1, SESSION_NAME_MAX_LENGTH - prefix.length - suffix.length); + const displayedRelayName = truncateRelayName(relayName, maxRelayNameLength); + return `${prefix}${displayedRelayName}${suffix}`; +} + +function truncateRelayName(relayName: string, maxLength: number): string { + if (relayName.length <= maxLength) return relayName; + const truncated = relayName.slice(0, maxLength).replace(/[\s._-]+$/g, "").trim(); + return truncated === "" ? relayName.slice(0, maxLength).trim() : truncated; +} + function textFromAssistant(message: AssistantMessage): string { return message.content .filter((part) => part.type === "text") From 8511604e8370fadd60b15f01a73a68df7f12f873 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 3 Jul 2026 11:55:59 +0200 Subject: [PATCH 050/111] test: close audit cleanup findings --- pi-web-plugins/updates/updatesLogic.test.ts | 4 ++- .../src/components/selectableRow.test.ts | 8 +++--- src/client/src/components/selectableRow.ts | 12 +++------ src/docker/piWebDockerDocs.test.ts | 26 ++++++++++++++++--- src/server/piWebPluginService.test.ts | 5 +++- src/shared/activity.test.ts | 11 ++------ src/shared/activity.ts | 10 ------- 7 files changed, 38 insertions(+), 38 deletions(-) diff --git a/pi-web-plugins/updates/updatesLogic.test.ts b/pi-web-plugins/updates/updatesLogic.test.ts index 1a3066d..b1e5c5d 100644 --- a/pi-web-plugins/updates/updatesLogic.test.ts +++ b/pi-web-plugins/updates/updatesLogic.test.ts @@ -233,9 +233,11 @@ describe("fallbackDockerStatus", () => { const fallback = fallbackDockerStatus({ dockerMode: "dev" }, "generated"); expect(fallback?.generatedAt).toBe("generated"); expect(fallback?.components.web.installation).toEqual({ kind: "docker", dockerMode: "dev" }); - expect(fallback?.commands).toMatchObject({ + expect(fallback?.commands).toEqual({ update: "pi-web-docker --dev update", restart: "pi-web-docker --dev restart", + restartWeb: "pi-web-docker --dev restart-web", + restartSessiond: "pi-web-docker --dev restart-sessiond", status: "pi-web-docker --dev status", }); expect(fallback?.messages[0]?.id).toBe("docker-status-compatibility"); diff --git a/src/client/src/components/selectableRow.test.ts b/src/client/src/components/selectableRow.test.ts index a49255b..48d028e 100644 --- a/src/client/src/components/selectableRow.test.ts +++ b/src/client/src/components/selectableRow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { activateSelectableRow, activateSelectableRowFromKeyboard, handleSelectableRowKeyboard } from "./selectableRow"; +import { activateSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; describe("selectable row activation", () => { it("activates rows from non-interactive click targets", () => { @@ -20,8 +20,8 @@ describe("selectable row activation", () => { const enter = keyboardEventWithPath("Enter", matchTarget(() => false)); const space = keyboardEventWithPath(" ", matchTarget(() => false)); - activateSelectableRowFromKeyboard(enter, enterAction); - activateSelectableRowFromKeyboard(space, spaceAction); + expect(handleSelectableRowKeyboard(enter, { activate: enterAction })).toBe(true); + expect(handleSelectableRowKeyboard(space, { activate: spaceAction })).toBe(true); expect(enterAction).toHaveBeenCalledOnce(); expect(spaceAction).toHaveBeenCalledOnce(); @@ -33,7 +33,7 @@ describe("selectable row activation", () => { const action = vi.fn(); const event = keyboardEventWithPath("Enter", matchTarget((selector: string) => selector.includes("button"))); - activateSelectableRowFromKeyboard(event, action); + expect(handleSelectableRowKeyboard(event, { activate: action })).toBe(false); expect(action).not.toHaveBeenCalled(); expect(event.preventDefault).not.toHaveBeenCalled(); diff --git a/src/client/src/components/selectableRow.ts b/src/client/src/components/selectableRow.ts index 1bda886..cbf7a90 100644 --- a/src/client/src/components/selectableRow.ts +++ b/src/client/src/components/selectableRow.ts @@ -11,8 +11,9 @@ const interactiveSelector = [ ].join(","); type ComposedPathEvent = Pick; -type SelectableKeyboardEvent = ComposedPathEvent & Pick; -type SelectableNavigationKeyboardEvent = SelectableKeyboardEvent & Partial>; +type SelectableNavigationKeyboardEvent = ComposedPathEvent + & Pick + & Partial>; export interface SelectableRowKeyboardOptions { activate: () => void; @@ -37,13 +38,6 @@ export function activateSelectableRow(event: ComposedPathEvent, action: () => vo action(); } -export function activateSelectableRowFromKeyboard(event: SelectableKeyboardEvent, action: () => void): void { - if (event.key !== "Enter" && event.key !== " ") return; - if (isFromInteractiveElement(event)) return; - event.preventDefault(); - action(); -} - export function handleSelectableRowKeyboard(event: SelectableNavigationKeyboardEvent, options: SelectableRowKeyboardOptions): boolean { if (isFromInteractiveElement(event)) return false; if (event.key === "Enter" || event.key === " ") { diff --git a/src/docker/piWebDockerDocs.test.ts b/src/docker/piWebDockerDocs.test.ts index 979e15e..682f112 100644 --- a/src/docker/piWebDockerDocs.test.ts +++ b/src/docker/piWebDockerDocs.test.ts @@ -37,10 +37,8 @@ describe("pi-web-docker documentation", () => { readRepoFile("docker/pi-web-docker"), ]); - for (const command of PI_WEB_DOCKER_USER_COMMANDS) { - expect(dockerReadme).toContain(`| \`${command}\` |`); - expect(dockerEntrypoint).toContain(command); - } + expect(readDockerCommandMatrix(dockerReadme)).toEqual([...PI_WEB_DOCKER_USER_COMMANDS]); + expect(readEntrypointCommandCases(dockerEntrypoint)).toEqual(new Set(PI_WEB_DOCKER_USER_COMMANDS)); expect(dockerReadme).toContain("`pi-web-docker --dev status`"); expect(dockerReadme).toContain("`./docker/pi-web-docker --dev start`"); @@ -49,6 +47,26 @@ describe("pi-web-docker documentation", () => { }); }); +function readDockerCommandMatrix(dockerReadme: string): string[] { + const commandMatrixSection = dockerReadme.split("### Command matrix\n")[1]?.split("\n### Installer options")[0] ?? ""; + return Array.from(commandMatrixSection.matchAll(/^\| `([^`]+)` \|/gm), (match) => { + const command = match[1]; + if (command === undefined) throw new Error("Docker command matrix row did not include a command"); + return command; + }); +} + +function readEntrypointCommandCases(dockerEntrypoint: string): Set { + const commandCaseBlock = dockerEntrypoint.slice(dockerEntrypoint.indexOf('case "$command_name" in')); + const commandCases = new Set(); + for (const line of commandCaseBlock.split("\n")) { + const match = /^ {2}([a-z][a-z-]*(?:\|[a-z][a-z-]*)*)(?:\|__run-detached)?\)$/.exec(line); + if (match?.[1] === undefined) continue; + for (const command of match[1].split("|")) commandCases.add(command); + } + return commandCases; +} + async function readRepoFile(relativePath: string): Promise { return await readFile(join(repoRoot, relativePath), "utf8"); } diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 517973c..733cfe0 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -67,7 +67,10 @@ describe("PiWebPluginService", () => { const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); const manifest = await service.manifest(); - expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/updates\/pi-web-plugin\.js\?v=\d+&piWebDockerMode=dev$/u); + const moduleUrl = new URL(manifest.plugins[0]?.module ?? "", "http://pi-web.test"); + expect(moduleUrl.pathname).toBe("/pi-web-plugins/updates/pi-web-plugin.js"); + expect(moduleUrl.searchParams.get("v")).toMatch(/^\d+$/u); + expect(moduleUrl.searchParams.get("piWebDockerMode")).toBe("dev"); }); it("discovers Pi package plugins through an injected package provider", async () => { diff --git a/src/shared/activity.test.ts b/src/shared/activity.test.ts index 7135a22..badf315 100644 --- a/src/shared/activity.test.ts +++ b/src/shared/activity.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isSessionActive, sessionActivityLabel, isWorkspaceActivityActive } from "./activity"; +import { isSessionActive, isWorkspaceActivityActive } from "./activity"; import type { SessionStatus, WorkspaceActivity } from "./apiTypes"; const idleStatus: SessionStatus = { @@ -14,17 +14,10 @@ const idleStatus: SessionStatus = { }; describe("activity helpers", () => { - it("detects and labels active session states consistently", () => { + it("detects active session states", () => { expect(isSessionActive(idleStatus)).toBe(false); - expect(sessionActivityLabel(idleStatus)).toBeUndefined(); - expect(isSessionActive({ ...idleStatus, isStreaming: true })).toBe(true); - expect(sessionActivityLabel({ ...idleStatus, isStreaming: true })).toBe("streaming"); - expect(isSessionActive({ ...idleStatus, pendingMessageCount: 2 })).toBe(true); - expect(sessionActivityLabel({ ...idleStatus, pendingMessageCount: 2 })).toBe("2 pending"); - - expect(sessionActivityLabel(idleStatus, { sessionId: "s1", phase: "active", label: "running tool", detail: "read", at: "now" })).toBe("running tool: read"); }); it("detects workspace activity presence without exposing details", () => { diff --git a/src/shared/activity.ts b/src/shared/activity.ts index dd97ae8..657b34b 100644 --- a/src/shared/activity.ts +++ b/src/shared/activity.ts @@ -8,16 +8,6 @@ export function isSessionActive(status?: SessionStatus, activity?: SessionActivi || (status?.pendingMessageCount ?? 0) > 0; } -export function sessionActivityLabel(status?: SessionStatus, activity?: SessionActivity): string | undefined { - if (activity?.phase === "active") return activity.detail !== undefined && activity.detail !== "" ? `${activity.label}: ${activity.detail}` : activity.label; - if (status === undefined) return undefined; - if (status.isCompacting) return "compacting"; - if (status.isBashRunning) return "bash"; - if (status.isStreaming) return "streaming"; - if (status.pendingMessageCount > 0) return `${String(status.pendingMessageCount)} pending`; - return undefined; -} - export function isWorkspaceActivityActive(activity: WorkspaceActivity | undefined): boolean { return activity !== undefined && (activity.hasSessionActivity || activity.hasTerminalActivity); } From 73b169a768c5c163b56dbffc095ed90ac3a27fd0 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 3 Jul 2026 21:37:48 +0200 Subject: [PATCH 051/111] test: close selected coverage gaps --- .../skills/code-quality-architecture/SKILL.md | 4 +- .agents/skills/testing-guide/SKILL.md | 87 +++++++ AGENTS.md | 6 + pi-web-plugins/updates/updatesLogic.test.ts | 23 ++ .../src/api/federatedRouteContract.test.ts | 1 + .../src/components/SettingsDialog.test.ts | 84 ++++++- .../components/WorkspaceFilesPanel.test.ts | 187 ++++++++++++++- .../settings/SettingsShortcutsPanel.test.ts | 115 +++++++++- .../src/controllers/authController.test.ts | 212 +++++++++++++++++- .../fileExplorerController.test.ts | 16 ++ .../src/promptAttachmentCapture.test.ts | 149 +++++++++++- .../src/runtime/terminalRuntime.test.ts | 61 ++++- src/server/machines/machineService.test.ts | 120 ++++++++++ src/server/piWebStatusCache.test.ts | 43 ++++ .../sessiond/sessionProxyRoutes.test.ts | 55 ++++- src/server/sessions/attachmentService.test.ts | 69 +++++- src/server/sessions/authService.test.ts | 51 ++++- .../sessions/oauthLoginFlowService.test.ts | 27 +++ src/server/terminals/terminalRoutes.test.ts | 29 ++- .../workspaces/fileContentService.test.ts | 12 + 20 files changed, 1333 insertions(+), 18 deletions(-) create mode 100644 .agents/skills/testing-guide/SKILL.md diff --git a/.agents/skills/code-quality-architecture/SKILL.md b/.agents/skills/code-quality-architecture/SKILL.md index 290e9b5..6be1e78 100644 --- a/.agents/skills/code-quality-architecture/SKILL.md +++ b/.agents/skills/code-quality-architecture/SKILL.md @@ -1,12 +1,14 @@ --- name: code-quality-architecture -description: Project code quality and architecture expectations for implementation, refactoring, planning, and code review. Use this skill whenever writing, modifying, reviewing, or planning code in this repository, especially when making architecture choices, introducing modules/services/components, managing side effects, dependencies, state, boundaries, or tests. Favor composable, contained, intention-revealing, separated, dependency-injected, testable code while respecting the idioms of the framework or library in use. +description: Project code quality and architecture expectations for implementation, refactoring, planning, and code review. Use this skill whenever writing, modifying, reviewing, or planning production code or architecture in this repository, especially when making architecture choices, introducing modules/services/components, managing side effects, dependencies, state, or boundaries. Favor composable, contained, intention-revealing, separated, dependency-injected, testable code while respecting the idioms of the framework or library in use. --- # Code quality and architecture expectations Use this skill as a design lens, not as a framework tutorial. The goal is to shape code so future agents and humans can understand it, change it safely, and test it without needing to reverse-engineer hidden coupling. +For test-specific strategy, test helper conventions, and UI test harness choices, use the `testing-guide` skill. This skill still treats testability as a production-code design concern. + Respect the project's existing conventions and the framework/library idioms already in use. If a dependency expects a particular pattern, such as inheritance, decorators, lifecycle hooks, or a registration API, use that pattern deliberately and keep the surrounding project code as simple and composable as possible. ## Values we optimize for diff --git a/.agents/skills/testing-guide/SKILL.md b/.agents/skills/testing-guide/SKILL.md new file mode 100644 index 0000000..752742d --- /dev/null +++ b/.agents/skills/testing-guide/SKILL.md @@ -0,0 +1,87 @@ +--- +name: testing-guide +description: Project testing guide and test architecture rules for this repository. Use this skill whenever writing, modifying, reviewing, or planning tests, closing coverage gaps, adding Vitest coverage, creating test helpers or fakes, testing Lit components/controllers/services/routes, triaging test failures, or deciding between unit/controller/component/integration approaches. This includes the repo rule for Lit TemplateResult event-handler extraction and when not to use it. +--- + +# Testing guide + +Use this skill for test-specific decisions in this repository. The goal is useful regression coverage without letting test helpers, mocks, or component harnesses become a second application that is harder to maintain than the code under test. + +For production-code design and testability seams, also use the `code-quality-architecture` skill. This guide owns test strategy, test helper conventions, and UI test escape hatches. + +## Core principles + +- Test behavior and contracts that matter, not branches for their own sake. +- Prefer the smallest layer that proves the behavior: pure helper, service, controller, route/API contract, component boundary, then broader integration. +- Keep tests deterministic. Fake clocks, browser globals, filesystem/process/network boundaries, and hard-to-trigger errors when needed. +- Assert observable outcomes: return values, state transitions, emitted calls/events, HTTP responses, rendered user-facing state, or durable side effects. +- Avoid asserting incidental implementation details unless the selected gap is specifically about that implementation contract. +- Keep setup readable. A small explicit fixture is better than a magical factory that hides the scenario. +- Clean up global stubs, fake timers, DOM state, and pending promises so tests do not leak into one another. + +## Choosing the test layer + +Prefer this order unless the behavior requires a higher layer: + +1. **Pure helper/service tests** for data shaping, validation, cache decisions, command construction, and conversion logic. +2. **Controller/runtime adapter tests** for state orchestration, endpoint selection, cancellation, timers, and injected collaborators. +3. **Route/API contract tests** for HTTP status mapping, path/query/body parsing, proxy allowlists, and compatibility contracts. +4. **Component-boundary tests** for UI event wiring and rendered state. Prefer real DOM/custom-element interaction when practical. +5. **Broad verification** (`npm run verify`) when a change is cross-cutting, changes shared helpers/types, or before final merge review. + +Do not jump to a broad UI or integration test just because it feels more realistic if a lower layer proves the same behavior with less noise and less flake risk. + +## Test helpers and fakes + +- Keep helpers local until reuse is clear. If a pattern appears in multiple files, consolidate deliberately rather than copy-pasting variants. +- Type helpers and fakes strictly; avoid `any` unless the test is intentionally modeling an untyped external boundary. +- Fake only the boundary needed for the scenario. Do not mock the unit under test or so many collaborators that the assertion stops proving real behavior. +- Prefer controllable promises, fake timers, and explicit injected dependencies over sleeps or timing guesses. +- Name helpers after the domain behavior they support, not the mechanics of the fake. + +## Lit component tests + +Prefer testing Lit components through public/component boundaries: + +- instantiate the component and set properties when that is the component contract; +- dispatch events against rendered DOM when a lightweight DOM harness is practical; +- assert user-visible rendered state or controller calls caused by user-like interactions. + +### TemplateResult event-handler extraction rule + +Lit `TemplateResult` event-handler extraction means calling `render()`, inspecting the returned template's `strings`/`values`, finding an event handler near a marker, and invoking that handler directly. It is an escape hatch, not the default. + +Use TemplateResult handler extraction only when all of these are true: + +1. The test is specifically verifying Lit template event wiring. +2. A DOM/custom-element render harness would add disproportionate setup, flakiness, or noise for the behavior being checked. +3. The assertion checks observable component/controller effects, not Lit internals. +4. The lookup is anchored to stable semantic markup, labels, or user-facing text rather than incidental handler order. +5. The test stays narrow; it is not trying to cover a full user flow, accessibility behavior, or visual/layout behavior. + +Do not use TemplateResult handler extraction for: + +- general content assertions; +- styling, layout, focus, keyboard navigation, or accessibility behavior; +- broad user flows where real DOM events are the point; +- scenarios with an existing public controller/service/helper seam; +- copying a new ad hoc helper variant into another file without reviewing whether a shared helper or DOM harness is now warranted. + +When using this escape hatch: + +- Add a short comment above the helper or test explaining why direct handler extraction is proportionate. +- Keep the helper small, type-guarded, and file-local unless reuse is already justified. +- Anchor searches to stable semantic markers such as accessible labels, button text, ids intentionally used by the component, or nearby form markup. +- Assert the behavior caused by the handler, such as state changes or calls to injected callbacks/controllers. +- Avoid assertions about the exact shape of Lit's private data beyond the minimum needed to find the handler; fail with clear errors if the template cannot be inspected. + +## Checks to run + +Run the narrowest meaningful check first: + +- Changed test file: `npm test -- --run `. +- Source or exported type changes: also run `npm run typecheck`. +- Non-trivial test helper, component, or lint-sensitive changes: run `npx eslint ` or `npm run lint` when broader lint coverage is needed. +- Cross-cutting changes or final merge review: prefer `npm run verify`. + +Record exact commands and results when working under relay/audit workflows or when handing work to another agent. diff --git a/AGENTS.md b/AGENTS.md index c503ccf..774ce44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,12 @@ If you make changes that affect `src/server/sessiond.ts`, session runtime owners Changes to the web/API/UI side generally only require the `pi-web-ui-dev.service` autoreload/restart path. +## Testing guidance + +Project-specific testing rules live in `.agents/skills/testing-guide/SKILL.md`. + +Use that skill whenever writing, modifying, reviewing, or planning tests, closing coverage gaps, triaging test failures, or creating test helpers/harnesses. Keep detailed testing conventions there rather than growing this top-level orientation file. + ## Configuration conventions - `$PI_WEB_DATA_DIR` (`~/.pi-web` by default) contains PI WEB-managed state such as `projects.json` and `machines.json`; do not treat it as the user-editable config API. diff --git a/pi-web-plugins/updates/updatesLogic.test.ts b/pi-web-plugins/updates/updatesLogic.test.ts index b1e5c5d..cdb64cf 100644 --- a/pi-web-plugins/updates/updatesLogic.test.ts +++ b/pi-web-plugins/updates/updatesLogic.test.ts @@ -76,6 +76,17 @@ describe("recommendedCommand", () => { expect(result).toEqual({ label: "Restart everything", command: "pi-web restart" }); }); + it("recommends restart when the session daemon is stale", () => { + const result = recommendedCommand(status({ + components: { + web: component(), + sessiond: component({ component: "sessiond", label: "Session daemon", stale: true }), + }, + commands: { restart: "pi-web restart" }, + })); + expect(result).toEqual({ label: "Restart everything", command: "pi-web restart" }); + }); + it("returns nothing when everything is current and available", () => { expect(recommendedCommand(status({ commands: { restart: "pi-web restart" } }))).toBeUndefined(); }); @@ -243,6 +254,18 @@ describe("fallbackDockerStatus", () => { expect(fallback?.messages[0]?.id).toBe("docker-status-compatibility"); }); + it("creates Docker runtime commands without the development prefix", () => { + const fallback = fallbackDockerStatus({ dockerMode: "runtime" }); + expect(fallback?.components.sessiond.installation).toEqual({ kind: "docker", dockerMode: "runtime" }); + expect(fallback?.commands).toEqual({ + update: "pi-web-docker update", + restart: "pi-web-docker restart", + restartWeb: "pi-web-docker restart-web", + restartSessiond: "pi-web-docker restart-sessiond", + status: "pi-web-docker status", + }); + }); + it("does not create a fallback without a Docker runtime hint", () => { expect(fallbackDockerStatus({})).toBeUndefined(); }); diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 6c044b5..ad1fd65 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -67,6 +67,7 @@ describe("federated route contract", () => { ignoreParseFailure(sessionsApi.cycleThinkingLevel(session, machineId)), ignoreParseFailure(sessionsApi.commands(session, machineId)), ignoreParseFailure(sessionsApi.prompt(session, "hello", "followUp", machineId)), + ignoreParseFailure(sessionsApi.saveAttachments(session, [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }], machineId, "uploads")), ignoreParseFailure(sessionsApi.shell(session, "ls", machineId)), ignoreParseFailure(sessionsApi.runCommand(session, "/help", machineId)), ignoreParseFailure(sessionsApi.respondToCommand(session, "req 1", "yes", machineId)), diff --git a/src/client/src/components/SettingsDialog.test.ts b/src/client/src/components/SettingsDialog.test.ts index 5fdca5b..e453492 100644 --- a/src/client/src/components/SettingsDialog.test.ts +++ b/src/client/src/components/SettingsDialog.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { TemplateResult } from "lit"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import { configApi, pluginsApi, type Machine, type MachineRuntime, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginInfo, type PiWebPluginsResponse } from "../api"; +import { configApi, piPackagesApi, pluginsApi, type Machine, type MachineRuntime, type PiPackageInfo, type PiPackageMutationResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginInfo, type PiWebPluginsResponse } from "../api"; import { SettingsDialog } from "./SettingsDialog"; afterEach(() => { @@ -319,6 +319,76 @@ describe("settings-dialog general settings machine targeting", () => { }); }); +describe("settings-dialog Pi package orchestration", () => { + it("loads package data from the selected machine and ignores stale target responses", async () => { + const remotePackages = { packages: [packageInfo("npm:@acme/tools")] }; + const staleLoad = deferred(); + const packagesSpy = vi.spyOn(piPackagesApi, "packages").mockReturnValue(staleLoad.promise); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.machineRuntime = runtimeWithPackageManagement; + + const loadPromise = callDialogPromise(dialog, "loadPackagesForTarget"); + expect(packagesSpy.mock.calls).toEqual([["remote-a"]]); + expect(getDialogProperty(dialog, "packageLoading")).toBe(true); + + dialog.machine = secondRemoteMachine; + callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); + staleLoad.resolve(remotePackages); + await loadPromise; + + expect(getDialogProperty(dialog, "packagesResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "packageError")).toBe(""); + expect(getDialogProperty(dialog, "packageMessage")).toBe(""); + expect(getDialogProperty(dialog, "packageLoading")).toBe(false); + }); + + it("runs remote package mutations against the selected machine without refreshing gateway plugins", async () => { + const installedPackages = [packageInfo("npm:@acme/new-tools")]; + const install = deferred(); + const installSpy = vi.spyOn(piPackagesApi, "install").mockReturnValue(install.promise); + const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("gateway", true)])); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.machineRuntime = runtimeWithPackageManagement; + + const installPromise = callDialogPromise(dialog, "installPiPackage", "npm:@acme/new-tools"); + + expect(installSpy.mock.calls).toEqual([["npm:@acme/new-tools", "remote-a"]]); + expect(getDialogProperty(dialog, "saving")).toBe(true); + expect(getDialogProperty(dialog, "packageOperation")).toEqual({ kind: "install", source: "npm:@acme/new-tools" }); + + install.resolve(packageMutationResponse("install", installedPackages, "npm:@acme/new-tools")); + await installPromise; + + expect(pluginsSpy).not.toHaveBeenCalled(); + expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: installedPackages }); + expect(getDialogProperty(dialog, "packageMessage")).toContain("Pi package installed on Lab Mac"); + expect(getDialogProperty(dialog, "packageMessage")).toContain("each idle PI WEB session on Lab Mac"); + expect(getDialogProperty(dialog, "packageError")).toBe(""); + expect(getDialogProperty(dialog, "packageOperation")).toBeUndefined(); + expect(getDialogProperty(dialog, "saving")).toBe(false); + }); + + it("refreshes gateway plugins after a local package mutation", async () => { + const updatedPackages = [packageInfo("npm:@acme/tools")]; + const refreshedPlugins = pluginsResponse([pluginInfo("browser-helper", true)]); + const updateSpy = vi.spyOn(piPackagesApi, "update").mockResolvedValue(packageMutationResponse("update", updatedPackages)); + const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins); + const dialog = new SettingsDialog(); + + await callDialogPromise(dialog, "updatePiPackage"); + + expect(updateSpy.mock.calls).toEqual([[undefined, "local"]]); + expect(pluginsSpy.mock.calls).toEqual([[]]); + expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: updatedPackages }); + expect(getDialogProperty(dialog, "pluginsResponse")).toBe(refreshedPlugins); + expect(getDialogProperty(dialog, "packageMessage")).toContain("Reload the browser page separately for PI WEB browser plugin changes"); + expect(getDialogProperty(dialog, "packageError")).toBe(""); + expect(getDialogProperty(dialog, "saving")).toBe(false); + }); +}); + describe("settings-dialog plugin settings machine targeting", () => { it("loads plugin config and plugin list from the selected machine", async () => { const config = configResponse({ plugins: { info: { enabled: true } } }); @@ -502,13 +572,15 @@ const secondRemoteMachine: Machine = { updatedAt: "2026-07-01T00:00:00.000Z", }; -const runtimeWithoutSelectedMachineSettings: MachineRuntime = { +const runtimeWithPackageManagement: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "2026-07-01T00:00:00.000Z", capabilities: [PI_WEB_CAPABILITIES.piPackagesManage], }; +const runtimeWithoutSelectedMachineSettings: MachineRuntime = runtimeWithPackageManagement; + function getDialogProperty(dialog: SettingsDialog, property: string): unknown { return Reflect.get(dialog, property); } @@ -600,6 +672,14 @@ function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo { }; } +function packageInfo(source: string): PiPackageInfo { + return { source, scope: "user", filtered: false, installedPath: `/pi/packages/${source}` }; +} + +function packageMutationResponse(action: PiPackageMutationResponse["action"], packages: PiPackageInfo[], source?: string): PiPackageMutationResponse { + return source === undefined ? { action, packages } : { action, source, packages }; +} + interface Deferred { promise: Promise; resolve: (value: T) => void; diff --git a/src/client/src/components/WorkspaceFilesPanel.test.ts b/src/client/src/components/WorkspaceFilesPanel.test.ts index 923fd83..937989c 100644 --- a/src/client/src/components/WorkspaceFilesPanel.test.ts +++ b/src/client/src/components/WorkspaceFilesPanel.test.ts @@ -1,6 +1,43 @@ -import { describe, expect, it, vi } from "vitest"; +import type { TemplateResult } from "lit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { initialAppState } from "../appState"; +import type { WorkspacePanelContext } from "../plugins/types"; import type { WorkspaceUploadBatchState } from "../workspaceUploadState"; -import { startDirectWorkspaceUpload, uploadBatchProgressValue, uploadBatchStatusLabel, workspaceUploadBatchesForScope, workspaceUploadReviewDefaults, workspaceUploadReviewError } from "./WorkspaceFilesPanel"; +import { WorkspaceFilesPanel, startDirectWorkspaceUpload, uploadBatchProgressValue, uploadBatchStatusLabel, workspaceUploadBatchesForScope, workspaceUploadReviewDefaults, workspaceUploadReviewError } from "./WorkspaceFilesPanel"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("workspace-files-panel upload review", () => { + it("opens review from the hidden file input and submits selected files with defaults", () => { + vi.stubGlobal("HTMLInputElement", FakeHTMLInputElement); + const files = [new File(["a"], "a.txt"), new File(["b"], "b.txt")]; + const onStartWorkspaceUpload = vi.fn(() => ({ batchId: "batch-1", done: Promise.resolve() })); + const panel = new WorkspaceFilesPanel(); + panel.context = workspacePanelContext({ workspaceUploadDefaultFolder: "project/uploads", onStartWorkspaceUpload }); + + const inputChange = findTemplateEventHandler(panel.render(), `id="workspace-upload-input"`); + const input = new FakeHTMLInputElement(files); + inputChange(new EventWithCurrentTarget("change", input)); + + expect(input.value).toBe(""); + expect(onStartWorkspaceUpload).not.toHaveBeenCalled(); + + const submit = findTemplateEventHandler(panel.render(), "
    (panel.render(), " { it("filters upload batches to the selected project, workspace, and machine", () => { @@ -80,6 +117,152 @@ describe("workspaceUploadReviewError", () => { }); }); +type TemplateEventHandler = (event: E) => void; + +function findTemplateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler { + const handler = findOptionalTemplateEventHandler(template, marker); + if (handler === undefined) throw new Error(`Expected template event handler after ${marker}`); + return handler; +} + +function findOptionalTemplateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler | undefined { + return findInTemplate(template); + + function findInTemplate(current: TemplateResult): TemplateEventHandler | undefined { + const strings = templateStrings(current); + const values = templateValues(current); + for (let index = 0; index < values.length; index += 1) { + const staticChunk = strings[index]; + const value = values[index]; + if (staticChunk !== undefined && staticChunk.includes(marker) && isTemplateEventHandler(value)) return value; + const nestedHandler = findInValue(value); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; + } + + function findInValue(value: unknown): TemplateEventHandler | undefined { + if (Array.isArray(value)) { + for (const item of value) { + const nestedHandler = findInValue(item); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; + } + if (isTemplateResult(value)) return findInTemplate(value); + return undefined; + } +} + +function templateStrings(template: TemplateResult): readonly string[] { + const strings = Reflect.get(template, "strings"); + if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); + return strings; +} + +function templateValues(template: TemplateResult): readonly unknown[] { + const values = Reflect.get(template, "values"); + if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable"); + return values.map((value: unknown) => value); +} + +function isTemplateResult(value: unknown): value is TemplateResult { + return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values")); +} + +function isTemplateEventHandler(value: unknown): value is TemplateEventHandler { + return typeof value === "function"; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); +} + +class FakeFileList implements FileList { + readonly length: number; + [index: number]: File; + + constructor(private readonly files: readonly File[]) { + this.length = files.length; + files.forEach((file, index) => { + this[index] = file; + }); + } + + item(index: number): File | null { + return this.files[index] ?? null; + } + + [Symbol.iterator](): ArrayIterator { + return this.files[Symbol.iterator](); + } +} + +class FakeHTMLInputElement extends EventTarget { + readonly files: FileList; + value = "selected-files"; + + constructor(files: readonly File[]) { + super(); + this.files = new FakeFileList(files); + } +} + +class EventWithCurrentTarget extends Event { + constructor(type: string, private readonly eventCurrentTarget: EventTarget) { + super(type); + } + + override get currentTarget(): EventTarget { + return this.eventCurrentTarget; + } +} + +class FakeSubmitEvent extends Event implements SubmitEvent { + readonly submitter: HTMLElement | null = null; +} + +function workspacePanelContext(patch: Partial> = {}): WorkspacePanelContext { + const workspace = { id: "workspace-1", projectId: "project-1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false }; + return { + machine: { id: "local", name: "Local", kind: "local" }, + workspace, + state: { ...initialAppState(), workspaceUploadBatches: {} }, + files: { + readFile: vi.fn(() => Promise.reject(new Error("not implemented"))), + writeFile: vi.fn(() => Promise.reject(new Error("not implemented"))), + deleteFile: vi.fn(() => Promise.reject(new Error("not implemented"))), + moveFile: vi.fn(() => Promise.reject(new Error("not implemented"))), + }, + prompt: { insertText: vi.fn(), getText: vi.fn(() => ""), getSelection: vi.fn(() => null) }, + terminal: { open: vi.fn(), runCommand: vi.fn(() => Promise.reject(new Error("not implemented"))) }, + host: { requestRender: vi.fn() }, + fileTree: [], + expandedDirs: {}, + selectedFilePath: undefined, + selectedFileContent: undefined, + fileTreeStale: false, + gitStatus: undefined, + selectedDiffPath: undefined, + selectedDiff: undefined, + selectedStagedDiff: undefined, + gitStale: false, + activeTerminalCount: 0, + selectedTerminalId: undefined, + terminalAutoStart: false, + workspaceUploadDefaultFolder: patch.workspaceUploadDefaultFolder ?? ".pi-web/uploads", + onRefreshFiles: vi.fn(), + onExpandDir: vi.fn(), + onSelectFile: vi.fn(), + onStartWorkspaceUpload: patch.onStartWorkspaceUpload ?? vi.fn(() => undefined), + onCancelWorkspaceUpload: vi.fn(), + onClearWorkspaceUpload: vi.fn(), + onRefreshGit: vi.fn(), + onSelectDiff: vi.fn(), + onSelectTerminal: vi.fn(), + }; +} + function uploadBatch(patch: Partial = {}): WorkspaceUploadBatchState { return { id: patch.id ?? "batch-1", diff --git a/src/client/src/components/settings/SettingsShortcutsPanel.test.ts b/src/client/src/components/settings/SettingsShortcutsPanel.test.ts index da082e2..7d67c4c 100644 --- a/src/client/src/components/settings/SettingsShortcutsPanel.test.ts +++ b/src/client/src/components/settings/SettingsShortcutsPanel.test.ts @@ -1,9 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { TemplateResult } from "lit"; +import type { AppAction } from "../../actions"; import type { PiWebConfigResponse, PiWebConfigValues } from "../../api"; import { SettingsShortcutsPanel } from "./SettingsShortcutsPanel"; import type { SettingsNotice } from "./SettingsPanelFrame"; +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe("settings-shortcuts-panel layout", () => { it("renders header, ordered notices, and shortcut settings through the shared frame", () => { const panel = new SettingsShortcutsPanel(); @@ -40,6 +45,34 @@ describe("settings-shortcuts-panel layout", () => { }); }); +describe("settings-shortcuts-panel shortcut row actions", () => { + it("saves edited shortcuts, disables them with None, and resets overrides", () => { + vi.stubGlobal("HTMLInputElement", FakeHTMLInputElement); + const onSave = vi.fn(); + const savePanel = panelWithShortcuts({ shortcuts: { "core:other": "mod+o" } }, onSave); + + findTemplateEventHandler(savePanel.render(), "@input=")( + new EventWithTarget("input", new FakeHTMLInputElement(" control + shift + p ")), + ); + + expectTextOrder(flattenTemplateContent(savePanel.render()), ["Open palette", "Ctrl+Shift+P", "Custom · Unsaved"]); + + findTemplateEventHandler(savePanel.render(), ">Save")(new Event("click")); + + const nonePanel = panelWithShortcuts({ shortcuts: { "core:open-palette": "mod+shift+p", "core:other": "mod+o" } }, onSave); + findTemplateEventHandler(nonePanel.render(), ">None")(new Event("click")); + + const resetPanel = panelWithShortcuts({ shortcuts: { "core:open-palette": null, "core:other": "mod+o" } }, onSave); + findTemplateEventHandler(resetPanel.render(), ">Reset")(new Event("click")); + + expect(onSave.mock.calls).toEqual([ + [{ shortcuts: { "core:other": "mod+o", "core:open-palette": "mod+shift+p" } }], + [{ shortcuts: { "core:open-palette": null, "core:other": "mod+o" } }], + [{ shortcuts: { "core:other": "mod+o" } }], + ]); + }); +}); + function frameNotices(template: TemplateResult): readonly SettingsNotice[] { const notices = collectTemplateValues(template).find(isSettingsNoticeArray); if (notices === undefined) throw new Error("Expected settings-panel-frame notices to be rendered"); @@ -139,6 +172,86 @@ function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); } +type SaveHandler = (config: PiWebConfigValues) => void | Promise; +type TemplateEventHandler = (event: E) => void; + +function panelWithShortcuts(config: PiWebConfigValues, onSave: SaveHandler): SettingsShortcutsPanel { + const panel = new SettingsShortcutsPanel(); + panel.actions = [shortcutAction()]; + panel.configResponse = configResponse(config); + panel.onSave = onSave; + return panel; +} + +function shortcutAction(): AppAction { + return { + id: "core:open-palette", + title: "Open palette", + description: "Open the command palette.", + shortcut: "mod+k", + group: "Navigation", + run: vi.fn(), + }; +} + +function findTemplateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler { + const handler = findOptionalTemplateEventHandler(template, marker); + if (handler === undefined) throw new Error(`Expected template event handler near ${marker}`); + return handler; +} + +function findOptionalTemplateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler | undefined { + return findInTemplate(template); + + function findInTemplate(current: TemplateResult): TemplateEventHandler | undefined { + const strings = templateStrings(current); + const values = templateValues(current); + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (isTemplateEventHandler(value) && templateEventHandlerMatches(strings, index, marker)) return value; + const nestedHandler = findInValue(value); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; + } + + function findInValue(value: unknown): TemplateEventHandler | undefined { + if (Array.isArray(value)) { + for (const item of value) { + const nestedHandler = findInValue(item); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; + } + if (isTemplateResult(value)) return findInTemplate(value); + return undefined; + } +} + +function templateEventHandlerMatches(strings: readonly string[], valueIndex: number, marker: string): boolean { + return (strings[valueIndex] ?? "").includes(marker) || (strings[valueIndex + 1] ?? "").includes(marker); +} + +function isTemplateEventHandler(value: unknown): value is TemplateEventHandler { + return typeof value === "function"; +} + +class FakeHTMLInputElement extends EventTarget { + constructor(readonly value: string) { + super(); + } +} + +class EventWithTarget extends Event { + constructor(type: string, private readonly eventTarget: EventTarget) { + super(type); + } + + override get target(): EventTarget { + return this.eventTarget; + } +} + function configResponse(config: PiWebConfigValues): PiWebConfigResponse { return { path: "/tmp/pi-web/config.json", diff --git a/src/client/src/controllers/authController.test.ts b/src/client/src/controllers/authController.test.ts index 5fa8c82..e344174 100644 --- a/src/client/src/controllers/authController.test.ts +++ b/src/client/src/controllers/authController.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { api as defaultApi, type AuthProviderOption, type OAuthFlowState } from "../api"; +import { api as defaultApi, type AuthProviderOption, type OAuthFlowState, type SessionInfo, type SessionStatus } from "../api"; import { initialAppState, type AppState } from "../appState"; import { AuthController, parseAuthSlashCommand } from "./authController"; @@ -42,20 +42,226 @@ describe("AuthController", () => { expect(getState().authDialog).toMatchObject({ step: "oauth", inputValue: "https://callback", responding: true }); }); + + it("resets OAuth prompt input and submit state when the request id changes", async () => { + const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); + const { controller, getState } = createController( + { authDialog: { step: "oauth", flow, inputValue: "https://callback", responding: true } }, + { + respondOAuthFlow: () => Promise.resolve(oauthFlow({ + select: { requestId: "request-2", message: "Choose an account", options: [{ value: "acct-1", label: "Account 1" }] }, + progress: ["Need account selection"], + })), + }, + ); + + await controller.respondOAuth(); + + expect(getState().authDialog).toMatchObject({ + step: "oauth", + flow: { select: { requestId: "request-2" } }, + inputValue: "", + responding: false, + }); + }); + + it("closes the OAuth dialog and refreshes selected session status when the flow completes", async () => { + const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); + const session = sessionInfo("session-1"); + const refreshedStatus = sessionStatus(session.id); + const respondCalls: { flowId: string; requestId: string; value: string; machineId: string | undefined }[] = []; + const statusCalls: { session: Parameters[0]; machineId: string | undefined }[] = []; + const appliedStatuses: SessionStatus[] = []; + const { controller, getState } = createController( + { selectedSession: session, authDialog: { step: "oauth", flow, inputValue: "https://callback" } }, + { + respondOAuthFlow: (flowId, requestId, value, machineId) => { + respondCalls.push({ flowId, requestId, value, machineId }); + return Promise.resolve(oauthFlow({ status: "complete" })); + }, + status: (sessionArg, machineId) => { + statusCalls.push({ session: sessionArg, machineId }); + return Promise.resolve(refreshedStatus); + }, + }, + (status) => { appliedStatuses.push(status); }, + ); + + await controller.respondOAuth(); + await flushMicrotasks(); + + expect(respondCalls).toEqual([{ flowId: "flow-1", requestId: "request-1", value: "https://callback", machineId: "local" }]); + expect(getState().authDialog).toBeUndefined(); + expect(statusCalls).toEqual([{ session, machineId: "local" }]); + expect(appliedStatuses).toEqual([refreshedStatus]); + }); + + it("leaves the OAuth dialog ready to retry if responding fails", async () => { + const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); + const { controller, getState } = createController( + { authDialog: { step: "oauth", flow, inputValue: "https://callback", responding: true } }, + { respondOAuthFlow: () => Promise.reject(new Error("Invalid callback")) }, + ); + + await controller.respondOAuth(); + + expect(getState().authDialog).toMatchObject({ + step: "oauth", + flow, + inputValue: "https://callback", + responding: false, + error: "Error: Invalid callback", + }); + }); + + it("cancels the active OAuth flow and closes the dialog even when cancellation fails", async () => { + const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); + const cancelCalls: { flowId: string; machineId: string | undefined }[] = []; + const { controller, getState } = createController( + { authDialog: { step: "oauth", flow } }, + { + cancelOAuthFlow: (flowId, machineId) => { + cancelCalls.push({ flowId, machineId }); + return Promise.reject(new Error("Cancel unavailable")); + }, + }, + ); + + await controller.cancelOAuth(); + + expect(cancelCalls).toEqual([{ flowId: "flow-1", machineId: "local" }]); + expect(getState().authDialog).toBeUndefined(); + }); + + it("validates API key input before saving and clears the validation error when edited", async () => { + const saveCalls: { providerId: string; key: string; machineId: string | undefined }[] = []; + const provider = authProvider("openai", "api_key"); + const { controller, getState } = createController( + { authDialog: { step: "apiKey", provider, value: " " } }, + { + saveApiKey: (providerId, key, machineId) => { + saveCalls.push({ providerId, key, machineId }); + return Promise.resolve({ accepted: true }); + }, + }, + ); + + await controller.saveApiKey(); + + expect(saveCalls).toEqual([]); + expect(getState().authDialog).toMatchObject({ step: "apiKey", error: "API key is required" }); + + controller.updateApiKey("sk-live"); + + expect(getState().authDialog).toMatchObject({ step: "apiKey", value: "sk-live" }); + expect(getState().authDialog).not.toHaveProperty("error"); + }); + + it("saves a trimmed API key on the selected machine and refreshes selected session status", async () => { + const saveCalls: { providerId: string; key: string; machineId: string | undefined }[] = []; + const statusCalls: { session: Parameters[0]; machineId: string | undefined }[] = []; + const appliedStatuses: SessionStatus[] = []; + const provider = authProvider("openai", "api_key"); + const session = sessionInfo("session-1"); + const refreshedStatus = sessionStatus(session.id); + const { controller, getState } = createController( + { + selectedMachine: remoteMachine("remote-1"), + selectedSession: session, + authDialog: { step: "apiKey", provider, value: " sk-live " }, + }, + { + saveApiKey: (providerId, key, machineId) => { + saveCalls.push({ providerId, key, machineId }); + return Promise.resolve({ accepted: true }); + }, + status: (sessionArg, machineId) => { + statusCalls.push({ session: sessionArg, machineId }); + return Promise.resolve(refreshedStatus); + }, + }, + (status) => { appliedStatuses.push(status); }, + ); + + await controller.saveApiKey(); + await flushMicrotasks(); + + expect(saveCalls).toEqual([{ providerId: "openai", key: "sk-live", machineId: "remote-1" }]); + expect(getState().authDialog).toBeUndefined(); + expect(statusCalls).toEqual([{ session, machineId: "remote-1" }]); + expect(appliedStatuses).toEqual([refreshedStatus]); + }); + + it("keeps the API key dialog open with an error if saving fails", async () => { + const provider = authProvider("openai", "api_key"); + const { controller, getState } = createController( + { authDialog: { step: "apiKey", provider, value: "sk-live" } }, + { saveApiKey: () => Promise.reject(new Error("Denied")) }, + ); + + await controller.saveApiKey(); + + expect(getState().authDialog).toMatchObject({ step: "apiKey", value: "sk-live", saving: false, error: "Error: Denied" }); + }); }); -function createController(statePatch: Partial, apiPatch: Partial = {}) { +function createController( + statePatch: Partial, + apiPatch: Partial = {}, + applyStatus: (status: SessionStatus) => void = () => undefined, +) { let state: AppState = { ...initialAppState(), ...statePatch }; const api = { ...defaultApi, ...apiPatch }; const controller = new AuthController( () => state, (patch) => { state = { ...state, ...patch }; }, - () => undefined, + applyStatus, { api }, ); return { controller, getState: () => state }; } +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function remoteMachine(id: string): NonNullable { + return { + id, + name: "Remote", + kind: "remote", + baseUrl: "https://remote.example", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function sessionInfo(id: string): SessionInfo { + return { + id, + cwd: "/repo", + path: `/tmp/${id}.jsonl`, + created: "2026-01-01T00:00:00.000Z", + modified: "2026-01-01T00:00:00.000Z", + messageCount: 0, + firstMessage: "", + }; +} + +function sessionStatus(sessionId: string): SessionStatus { + return { + sessionId, + isStreaming: false, + isCompacting: false, + isBashRunning: false, + pendingMessageCount: 0, + queuedMessages: [], + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost: 0, + }; +} + function authProvider(id: string, authType: "oauth" | "api_key"): AuthProviderOption { return { id, authType, name: `${id} ${authType}`, status: { configured: false } }; } diff --git a/src/client/src/controllers/fileExplorerController.test.ts b/src/client/src/controllers/fileExplorerController.test.ts index 4cf87e5..4217d4c 100644 --- a/src/client/src/controllers/fileExplorerController.test.ts +++ b/src/client/src/controllers/fileExplorerController.test.ts @@ -158,6 +158,22 @@ describe("FileExplorerController workspace uploads", () => { }); }); + it("clears an in-flight upload by cancelling the request and removing the batch", async () => { + const upload = controllableUpload({ rejectOnCancel: true }); + const harness = createHarness({ uploadWorkspaceFiles: upload.fn }); + const run = harness.controller.startWorkspaceUpload([new File(["aa"], "a.txt")], { destinationFolder: "uploads" }); + + expect(run?.batchId).toBe("batch-1"); + expect(harness.state.workspaceUploadBatches["batch-1"]?.status).toBe("uploading"); + + harness.controller.clearWorkspaceUpload(run?.batchId ?? "missing"); + await run?.done; + + expect(upload.cancel).toHaveBeenCalledTimes(1); + expect(harness.state.workspaceUploadBatches).toEqual({}); + expect(harness.state.error).toBe(""); + }); + it("keeps per-file errors accurate and refreshes after partial batch success", async () => { const upload = controllableUpload(); const harness = createHarness({ uploadWorkspaceFiles: upload.fn, now: sequenceNow("start", "fail") }); diff --git a/src/client/src/promptAttachmentCapture.test.ts b/src/client/src/promptAttachmentCapture.test.ts index 61dbd26..d25ee37 100644 --- a/src/client/src/promptAttachmentCapture.test.ts +++ b/src/client/src/promptAttachmentCapture.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import type { TemplateResult } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { PromptEditor } from "./components/PromptEditor"; import { capturePromptAttachments, DEFAULT_FILE_MIME_TYPE, effectivePromptAttachmentDelivery, READ_FAILURE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture"; function file(name: string, type: string, size = 10): CapturableFile { @@ -79,3 +81,148 @@ describe("effectivePromptAttachmentDelivery", () => { ])).toBe("folder"); }); }); + +describe("PromptEditor attachment chips", () => { + it("removes a pending attachment chip before sending the remaining attachments", () => { + const editor = new PromptEditor(); + const onSend = vi.fn>(); + editor.onSend = onSend; + setPromptEditorPrivate(editor, "draft", "please review"); + setPromptEditorPrivate(editor, "attachments", [ + { id: "attachment-1", kind: "file", name: "report.pdf", mimeType: "application/pdf", data: "UkVQT1JU", size: 6 }, + { id: "attachment-2", kind: "image", name: "shot.png", mimeType: "image/png", data: "UE5H", size: 3 }, + ]); + + const removeReport = findTemplateEventHandlerAfterValue(editor.render(), "Remove report.pdf", "@click="); + removeReport(new Event("click")); + + expect(templateContainsValue(editor.render(), "Remove report.pdf")).toBe(false); + expect(templateContainsValue(editor.render(), "Remove shot.png")).toBe(true); + + const send = findTemplateEventHandlerAfterMarker(editor.render(), "send-button"); + send(new Event("click")); + + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith("please review", undefined, [ + { kind: "image", mimeType: "image/png", data: "UE5H", name: "shot.png" }, + ], "inline"); + }); +}); + +type TemplateEventHandler = (event: E) => void; + +function setPromptEditorPrivate(editor: PromptEditor, property: string, value: unknown): void { + if (!Reflect.set(editor, property, value)) throw new Error(`Failed to set PromptEditor ${property}`); +} + +function findTemplateEventHandlerAfterMarker(template: TemplateResult, marker: string): TemplateEventHandler { + const handler = findOptionalTemplateEventHandlerAfterMarker(template, marker); + if (handler === undefined) throw new Error(`Expected template event handler after marker ${marker}`); + return handler; +} + +function findOptionalTemplateEventHandlerAfterMarker(template: TemplateResult, marker: string): TemplateEventHandler | undefined { + const strings = templateStrings(template); + const values = templateValues(template); + for (let index = 0; index < values.length; index += 1) { + const staticChunk = strings[index]; + if (staticChunk?.includes(marker) === true) { + const handler = nextTemplateEventHandler(values, index); + if (handler !== undefined) return handler; + } + const nestedHandler = findOptionalTemplateEventHandlerAfterMarkerInValue(values[index], marker); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; +} + +function findOptionalTemplateEventHandlerAfterMarkerInValue(value: unknown, marker: string): TemplateEventHandler | undefined { + if (Array.isArray(value)) { + for (const item of value) { + const nestedHandler = findOptionalTemplateEventHandlerAfterMarkerInValue(item, marker); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; + } + if (isTemplateResult(value)) return findOptionalTemplateEventHandlerAfterMarker(value, marker); + return undefined; +} + +function findTemplateEventHandlerAfterValue(template: TemplateResult, expectedValue: unknown, marker: string): TemplateEventHandler { + const handler = findOptionalTemplateEventHandlerAfterValue(template, expectedValue, marker); + if (handler === undefined) throw new Error(`Expected template event handler after value ${String(expectedValue)}`); + return handler; +} + +function findOptionalTemplateEventHandlerAfterValue(template: TemplateResult, expectedValue: unknown, marker: string): TemplateEventHandler | undefined { + const strings = templateStrings(template); + const values = templateValues(template); + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (value === expectedValue) { + for (let handlerIndex = index + 1; handlerIndex < values.length; handlerIndex += 1) { + const staticChunk = strings[handlerIndex]; + const maybeHandler = values[handlerIndex]; + if (staticChunk?.includes(marker) === true && isTemplateEventHandler(maybeHandler)) return maybeHandler; + } + } + const nestedHandler = findOptionalTemplateEventHandlerAfterValueInValue(value, expectedValue, marker); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; +} + +function findOptionalTemplateEventHandlerAfterValueInValue(value: unknown, expectedValue: unknown, marker: string): TemplateEventHandler | undefined { + if (Array.isArray(value)) { + for (const item of value) { + const nestedHandler = findOptionalTemplateEventHandlerAfterValueInValue(item, expectedValue, marker); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; + } + if (isTemplateResult(value)) return findOptionalTemplateEventHandlerAfterValue(value, expectedValue, marker); + return undefined; +} + +function nextTemplateEventHandler(values: readonly unknown[], startIndex: number): TemplateEventHandler | undefined { + for (let index = startIndex; index < values.length; index += 1) { + const value = values[index]; + if (isTemplateEventHandler(value)) return value; + } + return undefined; +} + +function templateContainsValue(template: TemplateResult, expectedValue: unknown): boolean { + return templateValues(template).some((value) => templateValueContains(value, expectedValue)); +} + +function templateValueContains(value: unknown, expectedValue: unknown): boolean { + if (value === expectedValue) return true; + if (Array.isArray(value)) return value.some((item) => templateValueContains(item, expectedValue)); + if (isTemplateResult(value)) return templateContainsValue(value, expectedValue); + return false; +} + +function templateStrings(template: TemplateResult): readonly string[] { + const strings = Reflect.get(template, "strings"); + if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); + return strings; +} + +function templateValues(template: TemplateResult): readonly unknown[] { + const values = Reflect.get(template, "values"); + if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable"); + return values.map((value: unknown) => value); +} + +function isTemplateResult(value: unknown): value is TemplateResult { + return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values")); +} + +function isTemplateEventHandler(value: unknown): value is TemplateEventHandler { + return typeof value === "function"; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); +} diff --git a/src/client/src/runtime/terminalRuntime.test.ts b/src/client/src/runtime/terminalRuntime.test.ts index 70c1c40..ad71de8 100644 --- a/src/client/src/runtime/terminalRuntime.test.ts +++ b/src/client/src/runtime/terminalRuntime.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { RunTerminalCommandInput, TerminalCommandRun, Workspace } from "../api"; +import type { RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, Workspace } from "../api"; import { createTerminalCommandRunsRuntime } from "./terminalRuntime"; const workspace: Workspace = { @@ -52,6 +52,32 @@ describe("terminal runtime", () => { await expect(handle.completed).resolves.toEqual(succeededRun); }); + it("passes through command-run lookup helpers and open requests", async () => { + const filter: TerminalCommandRunFilter = { + projectId: "p1", + workspaceId: "w1", + statuses: ["running"], + metadata: { "pi.operation": "test" }, + }; + const runs = [runningRun, succeededRun]; + const openTerminal = vi.fn(); + const api = { + runTerminalCommand: vi.fn(), + listCommandRuns: vi.fn(() => Promise.resolve(runs)), + getCommandRun: vi.fn(() => Promise.resolve(succeededRun)), + }; + const runtime = createTerminalCommandRunsRuntime("core", { api, openTerminal }); + + await expect(runtime.listCommandRuns(filter)).resolves.toEqual(runs); + await expect(runtime.getCommandRun("run1")).resolves.toEqual(succeededRun); + runtime.open({ terminalId: "t2" }); + + expect(api.listCommandRuns).toHaveBeenCalledWith(filter); + expect(api.getCommandRun).toHaveBeenCalledWith("run1"); + expect(openTerminal).toHaveBeenCalledWith(undefined, { terminalId: "t2" }); + expect(api.runTerminalCommand).not.toHaveBeenCalled(); + }); + it("polls command-run records until completion", async () => { vi.useFakeTimers(); const api = { @@ -73,4 +99,37 @@ describe("terminal runtime", () => { await expect(handle.completed).resolves.toEqual(succeededRun); expect(api.getCommandRun).toHaveBeenCalledWith("run1"); }); + + it("rejects completion polling failures and clears the scheduled timer", async () => { + const pollError = new Error("poll failed"); + const timerId = globalThis.setTimeout(() => undefined, 0); + globalThis.clearTimeout(timerId); + const scheduledPolls: (() => void)[] = []; + const clearTimeout = vi.fn(); + const api = { + runTerminalCommand: vi.fn(() => Promise.resolve(runningRun)), + listCommandRuns: vi.fn(), + getCommandRun: vi.fn(() => Promise.reject(pollError)), + }; + const runtime = createTerminalCommandRunsRuntime("core", { + api, + openTerminal: vi.fn(), + pollIntervalMs: 25, + setTimeout: (handler) => { + scheduledPolls.push(handler); + return timerId; + }, + clearTimeout, + }); + + const handle = await runtime.runCommand({ workspace, title: "Build", command: "npm run build" }); + const poll = scheduledPolls[0]; + expect(poll).toBeDefined(); + poll?.(); + + await expect(handle.completed).rejects.toBe(pollError); + expect(api.getCommandRun).toHaveBeenCalledWith("run1"); + expect(scheduledPolls).toHaveLength(1); + expect(clearTimeout).toHaveBeenCalledWith(timerId); + }); }); diff --git a/src/server/machines/machineService.test.ts b/src/server/machines/machineService.test.ts index f0f8714..0dfe4c5 100644 --- a/src/server/machines/machineService.test.ts +++ b/src/server/machines/machineService.test.ts @@ -2,6 +2,9 @@ import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PiWebRuntimeResponse } from "../../shared/apiTypes.js"; +import { PI_WEB_CAPABILITIES } from "../../shared/capabilities.js"; +import type { MachineClient } from "./machineClient.js"; import { MachineService } from "./machineService.js"; import { MachineStore, machineStorePath } from "./machineStore.js"; @@ -97,6 +100,90 @@ describe("MachineService", () => { }); }); + it("fetches and caches remote runtime through the configured client", async () => { + const body = remoteRuntimeBody(); + const requestJson = vi.fn(() => Promise.resolve({ statusCode: 200, headers: {}, body })); + const factoryMachines: unknown[] = []; + const remoteService = new MachineService(new MachineStore(storePath), { + remoteClientFactory: (machine) => { + factoryMachines.push(machine); + return fakeRemoteClient({ requestJson }); + }, + now: () => new Date("2026-05-25T00:00:00.000Z"), + runtimeCacheTtlMs: 10_000, + }); + const machine = await remoteService.add({ + name: " Remote ", + baseUrl: "https://remote.example.test/", + token: "secret", + headers: { "X-Pi-Web-Test": "yes" }, + }); + + const first = await remoteService.runtime(machine.id); + const second = await remoteService.runtime(machine.id); + + expect(first).toEqual({ + machineId: machine.id, + ok: true, + checkedAt: "2026-05-25T00:00:00.000Z", + packageName: body.packageName, + generatedAt: body.generatedAt, + components: body.components, + capabilities: body.capabilities, + }); + expect(second).toEqual(first); + expect(requestJson).toHaveBeenCalledTimes(1); + expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 }); + expect(factoryMachines).toEqual([ + expect.objectContaining({ + id: machine.id, + name: "Remote", + baseUrl: "https://remote.example.test", + token: "secret", + headers: { "X-Pi-Web-Test": "yes" }, + }), + ]); + }); + + it("caches remote runtime errors and clears them after remote updates", async () => { + let now = new Date("2026-05-25T00:00:00.000Z"); + const body = remoteRuntimeBody(); + const requestJson = vi.fn() + .mockRejectedValueOnce(new Error("network down")) + .mockResolvedValueOnce({ statusCode: 200, headers: {}, body }); + const remoteService = new MachineService(new MachineStore(storePath), { + remoteClientFactory: () => fakeRemoteClient({ requestJson }), + now: () => now, + runtimeCacheTtlMs: 10_000, + }); + const machine = await remoteService.add({ name: "Remote", baseUrl: "https://remote.example.test" }); + + const errorRuntime = await remoteService.runtime(machine.id); + now = new Date("2026-05-25T00:00:01.000Z"); + const cachedErrorRuntime = await remoteService.runtime(machine.id); + await remoteService.update(machine.id, { name: "Remote Updated" }); + now = new Date("2026-05-25T00:00:02.000Z"); + const refreshedRuntime = await remoteService.runtime(machine.id); + + expect(errorRuntime).toEqual({ + machineId: machine.id, + ok: false, + checkedAt: "2026-05-25T00:00:00.000Z", + error: "network down", + }); + expect(cachedErrorRuntime).toEqual(errorRuntime); + expect(refreshedRuntime).toEqual({ + machineId: machine.id, + ok: true, + checkedAt: "2026-05-25T00:00:02.000Z", + packageName: body.packageName, + generatedAt: body.generatedAt, + components: body.components, + capabilities: body.capabilities, + }); + expect(requestJson).toHaveBeenCalledTimes(2); + }); + it("does not allow local machine mutation", async () => { await expect(service.update("local", { name: "Other" })).rejects.toThrow("Local machine cannot be changed"); await expect(service.remove("local")).rejects.toThrow("Local machine cannot be deleted"); @@ -112,3 +199,36 @@ async function expectOwnerOnlyMachineStore(path: string): Promise { if (process.platform === "win32") return; expect((await stat(path)).mode & 0o777).toBe(0o600); } + +function remoteRuntimeBody(): PiWebRuntimeResponse { + return { + packageName: "@jmfederico/pi-web", + generatedAt: "2026-05-25T00:00:00.000Z", + components: { + web: { + component: "web", + label: "Remote Web", + runtimeVersion: "1.0.0", + available: true, + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage], + }, + sessiond: { + component: "sessiond", + label: "Remote Session daemon", + runtimeVersion: "1.0.0", + available: true, + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived], + }, + }, + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage], + }; +} + +function fakeRemoteClient(overrides: Partial): MachineClient { + return { + request: () => { throw new Error("HTTP request not configured for test"); }, + requestJson: () => { throw new Error("JSON request not configured for test"); }, + connectWebSocket: () => { throw new Error("WebSocket not configured for test"); }, + ...overrides, + }; +} diff --git a/src/server/piWebStatusCache.test.ts b/src/server/piWebStatusCache.test.ts index 44170f3..0d94142 100644 --- a/src/server/piWebStatusCache.test.ts +++ b/src/server/piWebStatusCache.test.ts @@ -31,6 +31,49 @@ describe("createPiWebStatusCache", () => { expect(load).toHaveBeenCalledTimes(2); }); + it("explicitly refreshes and replaces a fresh cached status", async () => { + let now = 1_000; + const load = vi.fn() + .mockResolvedValueOnce(status("first")) + .mockResolvedValueOnce(status("second")); + const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now }); + + await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" }); + now = 1_050; + + await expect(cache.refresh()).resolves.toMatchObject({ generatedAt: "second" }); + await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" }); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("retains stale status and reports background refresh errors", async () => { + let now = 1_000; + const refreshError = new Error("refresh failed"); + const errorReported = createDeferred(); + const onError = vi.fn((error: unknown) => { + errorReported.resolve(error); + }); + const load = vi.fn() + .mockResolvedValueOnce(status("first")) + .mockRejectedValueOnce(refreshError) + .mockResolvedValueOnce(status("second")); + const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now, onError }); + + await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" }); + now = 1_101; + + await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" }); + await expect(errorReported.promise).resolves.toBe(refreshError); + expect(onError).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledTimes(2); + + await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" }); + await waitForMicrotasks(); + + await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" }); + expect(load).toHaveBeenCalledTimes(3); + }); + it("deduplicates concurrent cold loads", async () => { const deferred = createDeferred(); const load = vi.fn(() => deferred.promise); diff --git a/src/server/sessiond/sessionProxyRoutes.test.ts b/src/server/sessiond/sessionProxyRoutes.test.ts index 32e0974..f6a19c5 100644 --- a/src/server/sessiond/sessionProxyRoutes.test.ts +++ b/src/server/sessiond/sessionProxyRoutes.test.ts @@ -36,6 +36,40 @@ describe("machine-scoped session proxy routes", () => { expect(daemon.requests).toEqual([{ method: "POST", path: "/auth/api-key", body: { providerId: "p", key: "k" } }]); }); + it("forwards sessiond health and runtime aliases to daemon endpoints", async () => { + const healthResponse = await app.inject({ method: "GET", url: "/api/machines/local/sessiond/health" }); + const runtimeResponse = await app.inject({ method: "GET", url: "/api/machines/local/sessiond/runtime" }); + + expect(healthResponse.statusCode).toBe(200); + expect(healthResponse.json()).toEqual({ ok: true }); + expect(runtimeResponse.statusCode).toBe(200); + expect(runtimeResponse.json()).toEqual({ ok: true }); + expect(daemon.requests).toEqual([ + { method: "GET", path: "/health", body: undefined }, + { method: "GET", path: "/runtime", body: undefined }, + ]); + }); + + it("forwards empty upstream responses without parsing a body", async () => { + daemon.respondWith({ statusCode: 204, headers: {}, body: "" }); + + const response = await app.inject({ method: "DELETE", url: "/api/machines/local/sessions/session-1" }); + + expect(response.statusCode).toBe(204); + expect(response.body).toBe(""); + expect(daemon.requests).toEqual([{ method: "DELETE", path: "/sessions/session-1", body: undefined }]); + }); + + it("returns a 502 response when the daemon request fails", async () => { + daemon.failWith(new Error("connection refused")); + + const response = await app.inject({ method: "GET", url: "/api/machines/local/sessions" }); + + expect(response.statusCode).toBe(502); + expect(response.json()).toEqual({ error: "Session daemon unavailable: connection refused" }); + expect(daemon.requests).toEqual([{ method: "GET", path: "/sessions", body: undefined }]); + }); + it("preserves cwd query context when forwarding session event websockets", async () => { await app.listen({ host: "127.0.0.1", port: 0 }); const socket = new WebSocket(`${serverUrl(app)}/api/machines/local/sessions/session-1/events?cwd=${encodeURIComponent("/repo")}`); @@ -49,9 +83,16 @@ describe("machine-scoped session proxy routes", () => { }); }); +interface FakeSessionDaemonResponse { + statusCode: number; + headers: Record; + body: string; +} + class FakeSessionDaemon { readonly requests: { method: string; path: string; body: unknown }[] = []; readonly websocketPaths: string[] = []; + private readonly queuedResponses: (FakeSessionDaemonResponse | Error)[] = []; private readonly sockets = new Set(); private constructor(private readonly upstream: WebSocketServer) { @@ -67,9 +108,19 @@ class FakeSessionDaemon { return new FakeSessionDaemon(upstream); } - request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }> { + respondWith(response: FakeSessionDaemonResponse): void { + this.queuedResponses.push(response); + } + + failWith(error: Error): void { + this.queuedResponses.push(error); + } + + request(method: string, path: string, body?: unknown): Promise { this.requests.push({ method, path, body }); - return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) }); + const queuedResponse = this.queuedResponses.shift(); + if (queuedResponse instanceof Error) return Promise.reject(queuedResponse); + return Promise.resolve(queuedResponse ?? { statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) }); } connectWebSocket(path: string): WebSocket { diff --git a/src/server/sessions/attachmentService.test.ts b/src/server/sessions/attachmentService.test.ts index 089c5ef..a41601d 100644 --- a/src/server/sessions/attachmentService.test.ts +++ b/src/server/sessions/attachmentService.test.ts @@ -1,13 +1,21 @@ import { mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DEFAULT_ATTACHMENT_FOLDER, saveAttachmentsToWorkspace } from "./attachmentService.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { formatDimensionNote, resizeImage, type ResizedImage } from "@earendil-works/pi-coding-agent"; +import { DEFAULT_ATTACHMENT_FOLDER, attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js"; + +vi.mock("@earendil-works/pi-coding-agent", () => ({ + formatDimensionNote: vi.fn(), + resizeImage: vi.fn(), +})); let workspace: string; let externalDirectories: string[] = []; beforeEach(async () => { + vi.mocked(formatDimensionNote).mockReset(); + vi.mocked(resizeImage).mockReset(); workspace = await mkdtemp(join(tmpdir(), "pi-web-attachments-")); externalDirectories = []; }); @@ -22,6 +30,63 @@ afterEach(async () => { const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); const pngBase64 = pngBytes.toString("base64"); +function resizedImage(overrides: Partial = {}): ResizedImage { + return { + data: "resized-data", + mimeType: "image/png", + originalWidth: 2400, + originalHeight: 1200, + width: 1200, + height: 600, + wasResized: true, + ...overrides, + }; +} + +describe("attachmentsToInlineImages", () => { + it("resizes images, drops unresizable images, and preserves dimension notes", async () => { + const firstInput = Buffer.from("first image"); + const droppedInput = Buffer.from("too large"); + const thirdInput = Buffer.from("third image"); + const firstResized = resizedImage({ data: "first-resized", mimeType: "image/webp" }); + const thirdResized = resizedImage({ + data: "third-resized", + mimeType: "image/jpeg", + originalWidth: 640, + originalHeight: 480, + width: 640, + height: 480, + wasResized: false, + }); + + vi.mocked(resizeImage) + .mockResolvedValueOnce(firstResized) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(thirdResized); + vi.mocked(formatDimensionNote) + .mockReturnValueOnce("[Image dimensions changed.]") + .mockReturnValueOnce(undefined); + + await expect(attachmentsToInlineImages([ + { kind: "image", mimeType: "image/png", data: firstInput.toString("base64"), name: "first.png" }, + { kind: "image", mimeType: "image/png", data: droppedInput.toString("base64"), name: "huge.png" }, + { kind: "image", mimeType: "image/jpeg", data: thirdInput.toString("base64"), name: "photo.jpg" }, + ])).resolves.toEqual([ + { + image: { type: "image", data: "first-resized", mimeType: "image/webp" }, + dimensionNote: "[Image dimensions changed.]", + }, + { image: { type: "image", data: "third-resized", mimeType: "image/jpeg" } }, + ]); + + expect(resizeImage).toHaveBeenNthCalledWith(1, firstInput, "image/png"); + expect(resizeImage).toHaveBeenNthCalledWith(2, droppedInput, "image/png"); + expect(resizeImage).toHaveBeenNthCalledWith(3, thirdInput, "image/jpeg"); + expect(formatDimensionNote).toHaveBeenNthCalledWith(1, firstResized); + expect(formatDimensionNote).toHaveBeenNthCalledWith(2, thirdResized); + }); +}); + describe("saveAttachmentsToWorkspace", () => { it("writes attachments into the default folder and returns relative paths", async () => { const fixedNow = () => new Date("2026-06-13T12:05:01.123Z"); diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index 3efe1b3..3218b9d 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -1,6 +1,8 @@ import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { OAuthFlowState } from "../../shared/apiTypes.js"; import { AuthService, type AuthChange } from "./authService.js"; +import { OAuthLoginFlowService } from "./oauthLoginFlowService.js"; describe("AuthService", () => { it("saves API keys and emits a global auth change", () => { @@ -30,6 +32,39 @@ describe("AuthService", () => { expect(changes).toEqual([]); auth.dispose(); }); + + it("refreshes auth state after OAuth login completes", () => { + const authStorage = AuthStorage.inMemory(); + const modelRegistry = ModelRegistry.create(authStorage); + const authFlows = new CapturingOAuthLoginFlowService(); + const auth = new AuthService({ modelRegistry, authFlows }); + const changes: AuthChange[] = []; + auth.subscribe((change) => { changes.push(change); }); + const reload = vi.spyOn(authStorage, "reload"); + const refresh = vi.spyOn(modelRegistry, "refresh"); + const provider = authStorage.getOAuthProviders().find((option) => option.id === "anthropic"); + if (provider === undefined) throw new Error("Expected built-in OAuth provider"); + + expect(auth.startOAuthLogin(provider.id)).toMatchObject({ providerId: provider.id, providerName: provider.name, status: "running" }); + + const startOptions = authFlows.startCalls.at(0); + if (startOptions === undefined) throw new Error("Expected OAuth flow to start"); + expect(startOptions.providerId).toBe(provider.id); + expect(startOptions.providerName).toBe(provider.name); + expect(startOptions.authStorage).toBe(authStorage); + expect(changes).toEqual([]); + + reload.mockClear(); + refresh.mockClear(); + if (startOptions.onComplete === undefined) throw new Error("Expected OAuth completion callback"); + startOptions.onComplete(); + + expect(reload).toHaveBeenCalledOnce(); + expect(refresh).toHaveBeenCalledOnce(); + expect(changes).toEqual([{}]); + auth.dispose(); + expect(authFlows.disposed).toBe(true); + }); }); function createAuthService(data: Parameters[0] = {}) { @@ -40,3 +75,17 @@ function createAuthService(data: Parameters[0] = {} auth.subscribe((change) => { changes.push(change); }); return { auth, authStorage, changes }; } + +class CapturingOAuthLoginFlowService extends OAuthLoginFlowService { + readonly startCalls: Parameters[0][] = []; + disposed = false; + + override start(options: Parameters[0]): OAuthFlowState { + this.startCalls.push(options); + return { flowId: "flow-1", providerId: options.providerId, providerName: options.providerName, status: "running", progress: [] }; + } + + override dispose(): void { + this.disposed = true; + } +} diff --git a/src/server/sessions/oauthLoginFlowService.test.ts b/src/server/sessions/oauthLoginFlowService.test.ts index 07b0fc3..24e3ab8 100644 --- a/src/server/sessions/oauthLoginFlowService.test.ts +++ b/src/server/sessions/oauthLoginFlowService.test.ts @@ -12,6 +12,7 @@ afterEach(() => { describe("OAuthLoginFlowService", () => { it("round-trips prompt responses and completes the flow", async () => { let promptValue: string | undefined; + const onComplete = vi.fn(); const service = new OAuthLoginFlowService(); const state = service.start({ providerId: "test-provider", @@ -22,6 +23,7 @@ describe("OAuthLoginFlowService", () => { promptValue = await callbacks.onPrompt({ message: "Paste code", placeholder: "code" }); callbacks.onProgress?.(`Got ${promptValue}`); }), + onComplete, }); const prompt = state.prompt; @@ -35,6 +37,7 @@ describe("OAuthLoginFlowService", () => { expect(promptValue).toBe("abc123"); expect(service.get(state.flowId)).toMatchObject({ status: "complete", progress: ["Waiting for code", "Got abc123", "Login complete"] }); + expect(onComplete).toHaveBeenCalledOnce(); service.dispose(); }); @@ -113,6 +116,30 @@ describe("OAuthLoginFlowService", () => { service.dispose(); }); + it("rejects pending prompts when disposed", async () => { + const promptRejected = deferred(); + const service = new OAuthLoginFlowService(); + const state = service.start({ + providerId: "test-provider", + providerName: "Test Provider", + authStorage: fakeAuthStorage(async (_providerId, callbacks) => { + try { + await callbacks.onPrompt({ message: "Paste code" }); + } catch (error) { + promptRejected.resolve(toError(error)); + throw error; + } + }), + }); + + expect(state.prompt).toBeDefined(); + + service.dispose(); + + await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login cancelled" }); + expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found"); + }); + it("rejects stale or duplicate responses", () => { const service = new OAuthLoginFlowService(); const state = service.start({ diff --git a/src/server/terminals/terminalRoutes.test.ts b/src/server/terminals/terminalRoutes.test.ts index 47bc2b3..c387046 100644 --- a/src/server/terminals/terminalRoutes.test.ts +++ b/src/server/terminals/terminalRoutes.test.ts @@ -43,7 +43,7 @@ describe("terminal routes", () => { expect(terminals.events).toEqual([`close-cwd:${requestCwd}`]); }); - it("routes command-run create, filter, cancel, and terminal continue requests", async () => { + it("routes command-run create, get, filter, cancel, and terminal continue requests", async () => { const createResponse = await app.inject({ method: "POST", url: "/terminal-command-runs", @@ -51,7 +51,16 @@ describe("terminal routes", () => { }); expect(createResponse.statusCode).toBe(200); - expect(createResponse.json()).toMatchObject({ id: "run1", terminalId: "t-run", status: "running" }); + const createdRun = createResponse.json(); + expect(createdRun).toMatchObject({ id: "run1", terminalId: "t-run", status: "running" }); + + const getResponse = await app.inject({ method: "GET", url: "/terminal-command-runs/run1" }); + expect(getResponse.statusCode).toBe(200); + expect(getResponse.json()).toEqual(createdRun); + + const missingGetResponse = await app.inject({ method: "GET", url: "/terminal-command-runs/missing" }); + expect(missingGetResponse.statusCode).toBe(404); + expect(missingGetResponse.json()).toEqual({ error: "Terminal command run not found" }); const listResponse = await app.inject({ method: "GET", url: `/terminal-command-runs?projectId=p1&statuses=running&metadata=${encodeURIComponent(JSON.stringify({ "pi.operation": "test" }))}` }); @@ -67,6 +76,22 @@ describe("terminal routes", () => { expect(continueResponse.statusCode).toBe(200); expect(terminals.events).toContain("continue:t-run"); }); + + it("rejects invalid command-run filter and metadata queries", async () => { + const invalidStatusResponse = await app.inject({ method: "GET", url: "/terminal-command-runs?statuses=running,stuck" }); + expect(invalidStatusResponse.statusCode).toBe(400); + expect(invalidStatusResponse.json()).toEqual({ error: "Invalid command run status: stuck" }); + + const arrayMetadataResponse = await app.inject({ method: "GET", url: `/terminal-command-runs?metadata=${encodeURIComponent(JSON.stringify(["not", "an", "object"]))}` }); + expect(arrayMetadataResponse.statusCode).toBe(400); + expect(arrayMetadataResponse.json()).toEqual({ error: "metadata filter must be an object" }); + + const nonStringMetadataResponse = await app.inject({ method: "GET", url: `/terminal-command-runs?metadata=${encodeURIComponent(JSON.stringify({ "pi.operation": 42 }))}` }); + expect(nonStringMetadataResponse.statusCode).toBe(400); + expect(nonStringMetadataResponse.json()).toEqual({ error: "metadata filter value must be a string: pi.operation" }); + + expect(terminals.filters).toEqual([]); + }); }); class FakeTerminals implements TerminalRouteService { diff --git a/src/server/workspaces/fileContentService.test.ts b/src/server/workspaces/fileContentService.test.ts index e2ab634..580d1a6 100644 --- a/src/server/workspaces/fileContentService.test.ts +++ b/src/server/workspaces/fileContentService.test.ts @@ -380,4 +380,16 @@ describe("moveWorkspaceFile", () => { expect(source.content).toBe("data"); await expect(readFile(join(outsideDir, "evil.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); }); + + it("prevents moving a source symlink that escapes the workspace", async () => { + const root = await tempWorkspace(); + const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-move-source-outside-")); + roots.push(outsideDir); + await writeFile(join(outsideDir, "secret.txt"), "secret"); + await symlink(join(outsideDir, "secret.txt"), join(root, "source-link.txt")); + + await expect(moveWorkspaceFile(root, "source-link.txt", "moved.txt")).rejects.toThrow("Path escapes workspace"); + await expect(readWorkspaceFile(root, "moved.txt")).rejects.toThrow("Path does not exist"); + await expect(readFile(join(outsideDir, "secret.txt"), "utf8")).resolves.toBe("secret"); + }); }); From 9006db1e6dae6aa58ac41ed2c7c27cfc8fbeeabe Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 4 Jul 2026 22:36:45 +0200 Subject: [PATCH 052/111] test: tolerate Windows line endings in Docker docs check --- src/docker/piWebDockerDocs.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/docker/piWebDockerDocs.test.ts b/src/docker/piWebDockerDocs.test.ts index 682f112..3dbc3d8 100644 --- a/src/docker/piWebDockerDocs.test.ts +++ b/src/docker/piWebDockerDocs.test.ts @@ -48,7 +48,8 @@ describe("pi-web-docker documentation", () => { }); function readDockerCommandMatrix(dockerReadme: string): string[] { - const commandMatrixSection = dockerReadme.split("### Command matrix\n")[1]?.split("\n### Installer options")[0] ?? ""; + const normalizedReadme = normalizeLineEndings(dockerReadme); + const commandMatrixSection = normalizedReadme.split("### Command matrix\n")[1]?.split("\n### Installer options")[0] ?? ""; return Array.from(commandMatrixSection.matchAll(/^\| `([^`]+)` \|/gm), (match) => { const command = match[1]; if (command === undefined) throw new Error("Docker command matrix row did not include a command"); @@ -57,7 +58,8 @@ function readDockerCommandMatrix(dockerReadme: string): string[] { } function readEntrypointCommandCases(dockerEntrypoint: string): Set { - const commandCaseBlock = dockerEntrypoint.slice(dockerEntrypoint.indexOf('case "$command_name" in')); + const normalizedEntrypoint = normalizeLineEndings(dockerEntrypoint); + const commandCaseBlock = normalizedEntrypoint.slice(normalizedEntrypoint.indexOf('case "$command_name" in')); const commandCases = new Set(); for (const line of commandCaseBlock.split("\n")) { const match = /^ {2}([a-z][a-z-]*(?:\|[a-z][a-z-]*)*)(?:\|__run-detached)?\)$/.exec(line); @@ -67,6 +69,10 @@ function readEntrypointCommandCases(dockerEntrypoint: string): Set { return commandCases; } +function normalizeLineEndings(content: string): string { + return content.replace(/\r\n?/g, "\n"); +} + async function readRepoFile(relativePath: string): Promise { return await readFile(join(repoRoot, relativePath), "utf8"); } From eb1727688f6b9b4bf73cae6f7c9d7b2f7a08ac7c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 4 Jul 2026 22:39:55 +0200 Subject: [PATCH 053/111] fix: preserve legacy federated session actions --- .../legacy-federated-session-actions.md | 5 ++ src/client/src/components/PiWebApp.ts | 11 +++- src/client/src/components/SessionList.test.ts | 16 +++-- src/client/src/components/SessionList.ts | 16 +++-- .../components/appShell/AppNavigationPanel.ts | 2 + .../src/controllers/sessionController.test.ts | 59 +++++++++++++++++-- .../src/controllers/sessionController.ts | 25 +++++--- src/client/src/plugins/core/actions.ts | 14 +++-- src/client/src/plugins/registry.test.ts | 15 ++++- src/client/src/sessionPersistence.ts | 31 ++++++++-- src/shared/apiTypes.ts | 1 + src/shared/capabilities.test.ts | 14 +++++ src/shared/capabilities.ts | 3 + 13 files changed, 175 insertions(+), 37 deletions(-) create mode 100644 .changeset/legacy-federated-session-actions.md diff --git a/.changeset/legacy-federated-session-actions.md b/.changeset/legacy-federated-session-actions.md new file mode 100644 index 0000000..45098d9 --- /dev/null +++ b/.changeset/legacy-federated-session-actions.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Preserve archive and archived-session delete actions for older federated PI WEB machines that do not yet advertise session persistence or delete capabilities. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index cdc4f6e..2626f9d 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -20,6 +20,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; import { selectedMachineId } from "../controllers/types"; import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi"; +import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence"; import { RealtimeSocket } from "../sessionSocket"; import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types"; import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme"; @@ -1016,7 +1017,10 @@ export class PiWebApp extends LitElement { private canDeleteArchivedSessions(): boolean { const runtime = this.selectedMachineRuntime(); - return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived); + // COMPAT-CAP sessions.deleteArchived: older federated machines may support + // the legacy DELETE route without advertising runtime capabilities. Only + // block when capability discovery succeeds and reports no support. + return runtime?.ok !== true || supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived); } private canReloadSessions(): boolean { @@ -1029,6 +1033,10 @@ export class PiWebApp extends LitElement { return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsCleanup); } + private hasAuthoritativeSessionPersistence(): boolean { + return runtimeHasAuthoritativeSessionPersistence(this.selectedMachineRuntime()); + } + private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean { if (machineId === "local") return true; // COMPAT-CAP workspace.fileSuggestions: remote machines without this @@ -1124,6 +1132,7 @@ export class PiWebApp extends LitElement { .canDeleteArchivedSessions=${this.canDeleteArchivedSessions()} .canReloadSessions=${this.canReloadSessions()} .canCleanupSessions=${this.canCleanupSessions()} + .authoritativeSessionPersistence=${this.hasAuthoritativeSessionPersistence()} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()} .cleanupUnavailableMessage=${this.sessionCleanupUnavailableMessage()} .collapsible=${true} diff --git a/src/client/src/components/SessionList.test.ts b/src/client/src/components/SessionList.test.ts index a1009fc..648a595 100644 --- a/src/client/src/components/SessionList.test.ts +++ b/src/client/src/components/SessionList.test.ts @@ -27,11 +27,17 @@ describe("sessionRowActivityKind", () => { }); describe("session action eligibility", () => { - it("requires a persisted server signal before archiving", () => { - expect(isArchivableSessionInfo(session("persisted", { persisted: true }))).toBe(true); - expect(isArchivableSessionInfo(session("unknown"))).toBe(false); - expect(isArchivableSessionInfo(session("transient", { persisted: false }))).toBe(false); - expect(isArchivableSessionInfo({ ...session("archived", { persisted: true }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" })).toBe(false); + it("requires a persisted server signal before archiving when persistence is authoritative", () => { + const authoritative = { authoritative: true }; + expect(isArchivableSessionInfo(session("persisted", { persisted: true }), undefined, authoritative)).toBe(true); + expect(isArchivableSessionInfo(session("unknown"), undefined, authoritative)).toBe(false); + expect(isArchivableSessionInfo(session("transient", { persisted: false }), undefined, authoritative)).toBe(false); + expect(isArchivableSessionInfo({ ...session("archived", { persisted: true }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" }, undefined, authoritative)).toBe(false); + }); + + it("preserves legacy archiving when persistence support is not advertised", () => { + expect(isArchivableSessionInfo(session("legacy"))).toBe(true); + expect(isTransientNewSessionInfo(session("legacy"))).toBe(false); }); it("allows deleting transient non-archived sessions from server or browser-cached signals", () => { diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index 67cbc55..ccde342 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -36,6 +36,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection @property({ type: Boolean }) canDeleteArchived = false; @property({ type: Boolean }) canReload = false; @property({ type: Boolean }) canCleanup = false; + @property({ type: Boolean }) authoritativeSessionPersistence = false; @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; @property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions."; @property({ type: Boolean, reflect: true }) collapsible = false; @@ -193,7 +194,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection if (visibleSessions.length === 0 || !this.selectionScopes.has("current")) return null; const selectedSessions = this.selectedSessions("current"); - const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id])); + const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions())); const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id)); const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length; return html` @@ -234,8 +235,9 @@ export class SessionList extends LitElement implements KeyboardNavigableSection const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id); const status = this.statuses[session.id]; const activity = this.activities[session.id]; - const canArchive = isArchivableSessionInfo(session, status); - const canDeleteTransient = isTransientNewSessionInfo(session, status); + const persistenceOptions = this.sessionPersistenceOptions(); + const canArchive = isArchivableSessionInfo(session, status, persistenceOptions); + const canDeleteTransient = isTransientNewSessionInfo(session, status, persistenceOptions); const canReloadSession = canArchive && this.canReload; return html`
    isArchivableSessionInfo(session, this.statuses[session.id])); + const sessions = this.selectedSessions("current").filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions())); this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessions.map((session) => session.id)); void this.onArchiveMany?.(sessions); } @@ -395,7 +397,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection } private renderSessionMetaPrefix(session: SessionInfo, status: SessionStatus | undefined, activity: SessionActivity | undefined) { - if (isTransientNewSessionInfo(session, status)) { + if (isTransientNewSessionInfo(session, status, this.sessionPersistenceOptions())) { if (activity?.phase === "active") return "creating · "; if (activity?.phase === "error") return "error · "; return "new · "; @@ -404,6 +406,10 @@ export class SessionList extends LitElement implements KeyboardNavigableSection return ""; } + private sessionPersistenceOptions() { + return { authoritative: this.authoritativeSessionPersistence }; + } + private renderActivity(session: SessionInfo) { const kind = sessionRowActivityKind(session, this.statuses[session.id], this.activities[session.id], this.sending[session.id] === true); return renderActionActivityIndicator(kind, kind === "sending" ? "Sending message" : "Session active"); diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 5a2fc40..9029fa7 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -44,6 +44,7 @@ export class AppNavigationPanel extends LitElement { @property({ type: Boolean }) canDeleteArchivedSessions = false; @property({ type: Boolean }) canReloadSessions = false; @property({ type: Boolean }) canCleanupSessions = false; + @property({ type: Boolean }) authoritativeSessionPersistence = false; @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; @property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions."; @property({ attribute: false }) onShowActions?: () => void; @@ -165,6 +166,7 @@ export class AppNavigationPanel extends LitElement { .canDeleteArchived=${this.canDeleteArchivedSessions} .canReload=${this.canReloadSessions} .canCleanup=${this.canCleanupSessions} + .authoritativeSessionPersistence=${this.authoritativeSessionPersistence} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage} .cleanupUnavailableMessage=${this.cleanupUnavailableMessage} .collapsible=${this.collapsible} diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 385de44..ba2977a 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -1124,6 +1124,31 @@ describe("SessionController", () => { expect(urlUpdates).toEqual([undefined]); }); + it("archives legacy sessions when persistence support is not advertised", async () => { + const legacySession = { ...oldSession }; + const archivedIds: string[] = []; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: legacySession, sessions: [legacySession] }; + const api: typeof defaultApi = { + ...defaultApi, + archive: (session) => { + archivedIds.push(sessionLookupId(session)); + return Promise.resolve({ archived: true }); + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.archiveSession(legacySession); + + expect(archivedIds).toEqual([legacySession.id]); + expect(state.sessions[0]).toMatchObject({ id: legacySession.id, archived: true }); + }); + it("archives selected session descendants and selects the next active session", async () => { const persistedSession = { ...oldSession, persisted: true }; const childSession = { ...oldSession, id: "child-session", path: "/tmp/child-session.jsonl", parentSessionPath: persistedSession.path, persisted: true }; @@ -1388,10 +1413,10 @@ describe("SessionController", () => { expect(state.sessionActivities[oldSession.id]).toBeUndefined(); }); - it("does not delete archived sessions when the selected machine runtime does not support it", async () => { + it("does not delete archived sessions when the selected machine runtime reports no support", async () => { const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; const deletedIds: string[] = []; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] }; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession], machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [] } } }; const api: typeof defaultApi = { ...defaultApi, deleteArchived: (session) => { @@ -1414,6 +1439,32 @@ describe("SessionController", () => { expect(state.error).toContain("requires an updated Pi-Web runtime"); }); + it("allows legacy archived-session deletion when runtime support is unknown", async () => { + const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; + const deletedIds: string[] = []; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: archivedSession, sessions: [archivedSession] }; + const api: typeof defaultApi = { + ...defaultApi, + deleteArchived: (session) => { + deletedIds.push(sessionLookupId(session)); + return Promise.resolve({ deleted: true }); + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.deleteArchivedSessions([archivedSession]); + + expect(deletedIds).toEqual([archivedSession.id]); + expect(state.sessions).toEqual([]); + expect(state.error).toBe(""); + }); + it("reloads the selected session from disk, discards the cached transcript, and re-fetches history", async () => { const persistedSession = { ...oldSession, persisted: true }; const cacheKey = sessionKey(oldSession.id); @@ -1496,14 +1547,14 @@ describe("SessionController", () => { expect(state.error).toContain("Reloading sessions from disk requires an updated Pi-Web runtime"); }); - it("does not reload sessions from disk without a persisted server signal", async () => { + it("does not reload sessions from disk without a persisted server signal when persistence is authoritative", async () => { const reloadCalls: string[] = []; let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession], - machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } }, + machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsPersistedState] } }, }; const api: typeof defaultApi = { ...defaultApi, diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 619687a..d9ec288 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -8,7 +8,7 @@ import { ChatTranscriptStore } from "../chatTranscriptStore"; import { isShellInput } from "../inputModes"; import { fileCompletionInsertText } from "../promptCompletions"; import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket"; -import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence"; +import { isArchivableSessionInfo, isTransientNewSessionInfo, sessionPersistenceOptionsForRuntime } from "../sessionPersistence"; import { isSessionActive } from "../../../shared/activity"; import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities"; import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; @@ -389,11 +389,12 @@ export class SessionController { async archiveSession(session = this.getState().selectedSession) { if (!session) return; const status = this.statusForSession(session); - if (isTransientNewSessionInfo(session, status)) { + const persistenceOptions = this.sessionPersistenceOptions(); + if (isTransientNewSessionInfo(session, status, persistenceOptions)) { await this.deleteCachedNewSession(session); return; } - if (!isArchivableSessionInfo(session, status)) return; + if (!isArchivableSessionInfo(session, status, persistenceOptions)) return; try { await this.api.archive(session, selectedMachineId(this.getState())); const state = this.getState(); @@ -409,7 +410,7 @@ export class SessionController { } async archiveSessionWithDescendants(session = this.getState().selectedSession) { - if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session))) return; + if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session), this.sessionPersistenceOptions())) return; try { const response = await this.api.archiveWithDescendants(session, selectedMachineId(this.getState())); const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id]; @@ -426,7 +427,8 @@ export class SessionController { } async archiveSessions(sessions: readonly SessionInfo[]): Promise { - const candidates = uniqueSessionsById(sessions).filter((session) => isArchivableSessionInfo(session, this.statusForSession(session))); + const persistenceOptions = this.sessionPersistenceOptions(); + const candidates = uniqueSessionsById(sessions).filter((session) => isArchivableSessionInfo(session, this.statusForSession(session), persistenceOptions)); if (candidates.length === 0) return; try { @@ -453,7 +455,9 @@ export class SessionController { const machineId = selectedMachineId(this.getState()); const runtime = this.getState().machineRuntimes[machineId]; - if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived)) { + // Preserve legacy federated deletes when capability discovery is unavailable; + // only a positive runtime response without support should block the action. + if (runtime?.ok === true && !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived)) { this.setState({ error: "Deleting archived sessions requires an updated Pi-Web runtime on this machine." }); return; } @@ -559,7 +563,7 @@ export class SessionController { } async deleteCachedNewSession(session = this.getState().selectedSession) { - if (session === undefined || !isTransientNewSessionInfo(session, this.statusForSession(session))) return; + if (session === undefined || !isTransientNewSessionInfo(session, this.statusForSession(session), this.sessionPersistenceOptions())) return; const pendingStart = isClientPendingStartSessionInfo(session) ? this.pendingSessionStarts.get(session.id) : undefined; if (pendingStart !== undefined) { pendingStart.discarded = true; @@ -605,7 +609,7 @@ export class SessionController { } async reloadSession(session = this.getState().selectedSession) { - if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session))) return; + if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session), this.sessionPersistenceOptions())) return; const machineId = selectedMachineId(this.getState()); const runtime = this.getState().machineRuntimes[machineId]; if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) { @@ -757,6 +761,11 @@ export class SessionController { return state.sessionStatuses[session.id]; } + private sessionPersistenceOptions() { + const state = this.getState(); + return sessionPersistenceOptionsForRuntime(state.machineRuntimes[selectedMachineId(state)]); + } + private workspaceSelectionKey(cwd: string): string { return `${selectedMachineId(this.getState())}:${cwd}`; } diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index 622fc42..322e1e9 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -2,7 +2,7 @@ import { isSessionActive } from "../../../../shared/activity"; import { PI_WEB_CAPABILITIES, supportsPiWebCapability, type PiWebCapability } from "../../../../shared/capabilities"; import type { AppState } from "../../appState"; import { selectedMachineId } from "../../controllers/types"; -import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../../sessionPersistence"; +import { isArchivableSessionInfo, isTransientNewSessionInfo, sessionPersistenceOptionsForRuntime } from "../../sessionPersistence"; import { isWorkspaceDeletionPending } from "../../workspaceDeletion"; import type { PluginAction } from "../types"; @@ -217,25 +217,29 @@ function hasDeletableWorkspace(context: { state: AppState }): boolean { } function hasArchivableSession(context: { state: AppState }): boolean { - return isArchivableSessionInfo(context.state.selectedSession, context.state.status); + return isArchivableSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state)); } function hasTransientNewSession(context: { state: AppState }): boolean { - return isTransientNewSessionInfo(context.state.selectedSession, context.state.status); + return isTransientNewSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state)); } function hasReloadableSession(context: { state: AppState }): boolean { - if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status)) return false; + if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state))) return false; if (reloadSessionDisabledReason(context) !== undefined) return false; return !isSessionActive(context.state.status, context.state.activity); } function reloadSessionDisabledReason(context: { state: AppState }): string | undefined { - if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status)) return undefined; + if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state))) return undefined; if (isSessionActive(context.state.status, context.state.activity)) return undefined; return missingCapabilityReason(context.state, PI_WEB_CAPABILITIES.sessionsReload, "reload sessions from disk"); } +function sessionPersistenceOptions(state: AppState) { + return sessionPersistenceOptionsForRuntime(state.machineRuntimes[selectedMachineId(state)]); +} + function missingCapabilityReason(state: AppState, capability: PiWebCapability, action: string): string | undefined { const runtime = state.machineRuntimes[selectedMachineId(state)]; if (runtime?.ok === true && supportsPiWebCapability(runtime, capability)) return undefined; diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index ce47538..bdb1de3 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -177,14 +177,19 @@ describe("PluginRegistry", () => { const registry = new PluginRegistry(); registry.register({ id: "core", plugin: corePlugin }); - const persistedActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }) }).context); + const persistedStateRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] } }; + const persistedActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: persistedStateRuntime }).context); expect(persistedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(true); expect(persistedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false); - const unknownActions = registry.getActions(createContext({ selectedSession: testSession() }).context); + const unknownActions = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: persistedStateRuntime }).context); expect(unknownActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false); expect(unknownActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false); + const legacyUnknownActions = registry.getActions(createContext({ selectedSession: testSession() }).context); + expect(legacyUnknownActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(true); + expect(legacyUnknownActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false); + const transientActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }) }).context); expect(transientActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false); expect(transientActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(true); @@ -214,7 +219,8 @@ describe("PluginRegistry", () => { it("enables session disk reload only for a writable session on a capable, idle runtime", () => { const registry = new PluginRegistry(); registry.register({ id: "core", plugin: corePlugin }); - const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } }; + const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsPersistedState] } }; + const legacyReloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } }; const reloadable = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: reloadRuntime }).context); const reloadableAction = reloadable.find((action) => action.id === "core:session.reload"); @@ -230,6 +236,9 @@ describe("PluginRegistry", () => { const unknown = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime }).context); expect(unknown.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); + const legacyUnknown = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: legacyReloadRuntime }).context); + expect(legacyUnknown.find((action) => action.id === "core:session.reload")?.enabled).toBe(true); + const transient = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }), machineRuntimes: reloadRuntime }).context); expect(transient.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); diff --git a/src/client/src/sessionPersistence.ts b/src/client/src/sessionPersistence.ts index 32db583..8cc8e3e 100644 --- a/src/client/src/sessionPersistence.ts +++ b/src/client/src/sessionPersistence.ts @@ -1,21 +1,40 @@ -import type { SessionInfo, SessionStatus } from "./api"; +import type { MachineRuntime, SessionInfo, SessionStatus } from "./api"; import { isCachedNewSessionInfo } from "./cachedNewSessions"; +import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../shared/capabilities"; export type SessionPersistenceState = "persisted" | "transient" | "unknown"; -export function sessionPersistenceState(session: SessionInfo | undefined, status?: SessionStatus): SessionPersistenceState { +export interface SessionPersistenceOptions { + /** + * True when the selected runtime advertises reliable persisted/transient + * session state. Legacy federated runtimes omit this field, so missing data + * must preserve the old "listed sessions are persisted" behavior. + */ + authoritative?: boolean; +} + +export function hasAuthoritativeSessionPersistence(runtime: Pick | undefined): boolean { + return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsPersistedState); +} + +export function sessionPersistenceOptionsForRuntime(runtime: Pick | undefined): SessionPersistenceOptions { + return { authoritative: hasAuthoritativeSessionPersistence(runtime) }; +} + +export function sessionPersistenceState(session: SessionInfo | undefined, status?: SessionStatus, options: SessionPersistenceOptions = {}): SessionPersistenceState { if (session === undefined) return "unknown"; const statusPersisted = status?.sessionId === session.id ? status.persisted : undefined; const persisted = statusPersisted ?? session.persisted; if (persisted === true) return "persisted"; if (persisted === false || isCachedNewSessionInfo(session)) return "transient"; + if (options.authoritative !== true) return "persisted"; return "unknown"; } -export function isArchivableSessionInfo(session: SessionInfo | undefined, status?: SessionStatus): boolean { - return session !== undefined && session.archived !== true && sessionPersistenceState(session, status) === "persisted"; +export function isArchivableSessionInfo(session: SessionInfo | undefined, status?: SessionStatus, options?: SessionPersistenceOptions): boolean { + return session !== undefined && session.archived !== true && sessionPersistenceState(session, status, options) === "persisted"; } -export function isTransientNewSessionInfo(session: SessionInfo | undefined, status?: SessionStatus): boolean { - return session !== undefined && session.archived !== true && sessionPersistenceState(session, status) === "transient"; +export function isTransientNewSessionInfo(session: SessionInfo | undefined, status?: SessionStatus, options?: SessionPersistenceOptions): boolean { + return session !== undefined && session.archived !== true && sessionPersistenceState(session, status, options) === "transient"; } diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 78e86b5..ee25823 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -6,6 +6,7 @@ export const PI_WEB_CAPABILITIES = { sessionsBulkMutations: "sessions.bulkMutations", sessionsCleanup: "sessions.cleanup", sessionsReload: "sessions.reload", + sessionsPersistedState: "sessions.persistedState", promptAttachments: "prompt.attachments", workspaceFileSuggestions: "workspace.fileSuggestions", piPackagesManage: "piPackages.manage", diff --git a/src/shared/capabilities.test.ts b/src/shared/capabilities.test.ts index 2e75074..aa03c5c 100644 --- a/src/shared/capabilities.test.ts +++ b/src/shared/capabilities.test.ts @@ -14,6 +14,20 @@ describe("PI WEB capabilities", () => { })).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]); }); + it("requires web and session daemon support for authoritative session persistence", () => { + expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState); + expect(SESSIOND_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState); + + expect(effectivePiWebCapabilities({ + web: { available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] }, + sessiond: { available: false, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] }, + })).not.toContain(PI_WEB_CAPABILITIES.sessionsPersistedState); + expect(effectivePiWebCapabilities({ + web: { available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] }, + sessiond: { available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] }, + })).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState); + }); + it("keeps only known string capabilities when parsing runtime data", () => { expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]); expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined(); diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts index b009081..8d00989 100644 --- a/src/shared/capabilities.ts +++ b/src/shared/capabilities.ts @@ -11,6 +11,7 @@ export const WEB_RUNTIME_CAPABILITIES = [ PI_WEB_CAPABILITIES.sessionsBulkMutations, PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsReload, + PI_WEB_CAPABILITIES.sessionsPersistedState, PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.workspaceFileSuggestions, PI_WEB_CAPABILITIES.piPackagesManage, @@ -22,6 +23,7 @@ export const SESSIOND_RUNTIME_CAPABILITIES = [ PI_WEB_CAPABILITIES.sessionsBulkMutations, PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsReload, + PI_WEB_CAPABILITIES.sessionsPersistedState, PI_WEB_CAPABILITIES.promptAttachments, ] as const satisfies readonly PiWebCapability[]; @@ -30,6 +32,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = { [PI_WEB_CAPABILITIES.sessionsBulkMutations]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"], + [PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"], [PI_WEB_CAPABILITIES.piPackagesManage]: ["web"], From a26654153b83fa90a2a9eb9c5f982a79f97eff17 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 4 Jul 2026 23:20:37 +0200 Subject: [PATCH 054/111] docs: shorten testing guide skill description --- .agents/skills/testing-guide/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/testing-guide/SKILL.md b/.agents/skills/testing-guide/SKILL.md index 752742d..baaf5af 100644 --- a/.agents/skills/testing-guide/SKILL.md +++ b/.agents/skills/testing-guide/SKILL.md @@ -1,6 +1,6 @@ --- name: testing-guide -description: Project testing guide and test architecture rules for this repository. Use this skill whenever writing, modifying, reviewing, or planning tests, closing coverage gaps, adding Vitest coverage, creating test helpers or fakes, testing Lit components/controllers/services/routes, triaging test failures, or deciding between unit/controller/component/integration approaches. This includes the repo rule for Lit TemplateResult event-handler extraction and when not to use it. +description: Repository-specific testing guide. Use for any test work: planning coverage, writing/fixing/reviewing Vitest tests, test helpers/fakes, failure triage, choosing test layers, and Lit UI tests, including TemplateResult handler extraction rules. --- # Testing guide From 9fed05850f12a07da073b325ce12446cedf6cb33 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 4 Jul 2026 23:29:50 +0200 Subject: [PATCH 055/111] test(workspaces): split file content service specs --- .../fileContentService.delete.test.ts | 81 ++++ .../fileContentService.move.test.ts | 127 ++++++ .../fileContentService.read.test.ts | 107 +++++ .../workspaces/fileContentService.test.ts | 395 ------------------ .../fileContentService.testSupport.ts | 15 + .../fileContentService.write.test.ts | 93 +++++ 6 files changed, 423 insertions(+), 395 deletions(-) create mode 100644 src/server/workspaces/fileContentService.delete.test.ts create mode 100644 src/server/workspaces/fileContentService.move.test.ts create mode 100644 src/server/workspaces/fileContentService.read.test.ts delete mode 100644 src/server/workspaces/fileContentService.test.ts create mode 100644 src/server/workspaces/fileContentService.testSupport.ts create mode 100644 src/server/workspaces/fileContentService.write.test.ts diff --git a/src/server/workspaces/fileContentService.delete.test.ts b/src/server/workspaces/fileContentService.delete.test.ts new file mode 100644 index 0000000..93c7fa4 --- /dev/null +++ b/src/server/workspaces/fileContentService.delete.test.ts @@ -0,0 +1,81 @@ +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { deleteWorkspaceFile, readWorkspaceFile } from "./fileContentService.js"; +import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js"; + +afterEach(async () => { + await cleanupTempWorkspaces(); +}); + +describe("deleteWorkspaceFile", () => { + it("deletes an existing file and returns existed: true", async () => { + const root = await createTempWorkspace(); + await writeFile(join(root, "notes.txt"), "hello"); + + const result = await deleteWorkspaceFile(root, "notes.txt"); + + expect(result).toMatchObject({ path: "notes.txt", existed: true }); + await expect(readWorkspaceFile(root, "notes.txt")).rejects.toThrow("Path does not exist"); + }); + + it("returns existed: false when deleting a non-existent file", async () => { + const root = await createTempWorkspace(); + + const result = await deleteWorkspaceFile(root, "missing.txt"); + + expect(result).toMatchObject({ path: "missing.txt", existed: false }); + }); + + it("rejects deleting a directory", async () => { + const root = await createTempWorkspace(); + await mkdir(join(root, "mydir"), { recursive: true }); + + await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory"); + }); + + it("rejects traversal and absolute paths", async () => { + const root = await createTempWorkspace(); + + await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed"); + await expect(deleteWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed"); + }); + + it("rejects missing path", async () => { + const root = await createTempWorkspace(); + + await expect(deleteWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required"); + await expect(deleteWorkspaceFile(root, "")).rejects.toThrow("path query parameter is required"); + }); + + it("deletes a symlink itself, not its target", async () => { + const root = await createTempWorkspace(); + const outsideDir = await createTempWorkspace("pi-web-outside-delete-"); + await writeFile(join(outsideDir, "real.txt"), "real content"); + // Create a symlink inside the workspace pointing outside + await symlink(join(outsideDir, "real.txt"), join(root, "link.txt")); + + const result = await deleteWorkspaceFile(root, "link.txt"); + + expect(result).toMatchObject({ path: "link.txt", existed: true }); + // The symlink should be gone, but the target file should still exist + await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow("Path does not exist"); + const realContent = await readFile(join(outsideDir, "real.txt"), "utf8"); + expect(realContent).toBe("real content"); + }); + + it("prevents deleting through a symlinked parent directory that escapes the workspace", async () => { + const root = await createTempWorkspace(); + await mkdir(join(root, "subdir"), { recursive: true }); + // A real file living outside the workspace that must not be deletable. + const outsideDir = await createTempWorkspace("pi-web-outside-delete-parent-"); + await writeFile(join(outsideDir, "victim.txt"), "important"); + // A symlinked parent directory inside the workspace pointing outside. + await symlink(outsideDir, join(root, "subdir", "escape"), "junction"); + + await expect(deleteWorkspaceFile(root, "subdir/escape/victim.txt")).rejects.toThrow("Path escapes workspace"); + // The outside file must survive. + const realContent = await readFile(join(outsideDir, "victim.txt"), "utf8"); + expect(realContent).toBe("important"); + }); +}); diff --git a/src/server/workspaces/fileContentService.move.test.ts b/src/server/workspaces/fileContentService.move.test.ts new file mode 100644 index 0000000..0d321f0 --- /dev/null +++ b/src/server/workspaces/fileContentService.move.test.ts @@ -0,0 +1,127 @@ +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { moveWorkspaceFile, readWorkspaceFile } from "./fileContentService.js"; +import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js"; + +afterEach(async () => { + await cleanupTempWorkspaces(); +}); + +describe("moveWorkspaceFile", () => { + it("moves a file to a new path", async () => { + const root = await createTempWorkspace(); + await writeFile(join(root, "original.txt"), "content"); + + const result = await moveWorkspaceFile(root, "original.txt", "moved.txt"); + + expect(result).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" }); + expect(result.size).toBe(7); + expect(Date.parse(result.modifiedAt)).not.toBeNaN(); + // Source should no longer exist + await expect(readWorkspaceFile(root, "original.txt")).rejects.toThrow("Path does not exist"); + // Target should exist + const target = await readWorkspaceFile(root, "moved.txt"); + expect(target.content).toBe("content"); + }); + + it("creates intermediate directories by default", async () => { + const root = await createTempWorkspace(); + await writeFile(join(root, "file.txt"), "data"); + + await moveWorkspaceFile(root, "file.txt", "deep/nested/dir/file.txt"); + + const target = await readWorkspaceFile(root, "deep/nested/dir/file.txt"); + expect(target.content).toBe("data"); + }); + + it("fails when createDirs is false and parent directory does not exist", async () => { + const root = await createTempWorkspace(); + await writeFile(join(root, "file.txt"), "data"); + + await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow(); + const source = await readWorkspaceFile(root, "file.txt"); + expect(source.content).toBe("data"); + }); + + it("overwrites target when overwrite is true", async () => { + const root = await createTempWorkspace(); + await writeFile(join(root, "source.txt"), "source content"); + await writeFile(join(root, "target.txt"), "target content"); + + const result = await moveWorkspaceFile(root, "source.txt", "target.txt", { overwrite: true }); + + expect(result.toPath).toBe("target.txt"); + const target = await readWorkspaceFile(root, "target.txt"); + expect(target.content).toBe("source content"); + }); + + it("throws when target exists and overwrite is false (default)", async () => { + const root = await createTempWorkspace(); + await writeFile(join(root, "source.txt"), "source"); + await writeFile(join(root, "target.txt"), "target"); + + await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists"); + // Source and target should remain unchanged + const source = await readWorkspaceFile(root, "source.txt"); + expect(source.content).toBe("source"); + const target = await readWorkspaceFile(root, "target.txt"); + expect(target.content).toBe("target"); + }); + + it("rejects source path traversal", async () => { + const root = await createTempWorkspace(); + + await expect(moveWorkspaceFile(root, "../secret.txt", "target.txt")).rejects.toThrow("Path traversal is not allowed"); + }); + + it("rejects target path traversal", async () => { + const root = await createTempWorkspace(); + await writeFile(join(root, "source.txt"), "data"); + + await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow("Path traversal is not allowed"); + const source = await readWorkspaceFile(root, "source.txt"); + expect(source.content).toBe("data"); + }); + + it("rejects moving a directory", async () => { + const root = await createTempWorkspace(); + await mkdir(join(root, "mydir"), { recursive: true }); + + await expect(moveWorkspaceFile(root, "mydir", "newdir")).rejects.toThrow("Source path is not a file"); + }); + + it("rejects missing fromPath or toPath", async () => { + const root = await createTempWorkspace(); + + await expect(moveWorkspaceFile(root, undefined, "target.txt")).rejects.toThrow("fromPath query parameter is required"); + await expect(moveWorkspaceFile(root, "source.txt", undefined)).rejects.toThrow("toPath query parameter is required"); + await expect(moveWorkspaceFile(root, "", "target.txt")).rejects.toThrow("fromPath query parameter is required"); + await expect(moveWorkspaceFile(root, "source.txt", "")).rejects.toThrow("toPath query parameter is required"); + }); + + it("prevents moving through symlinks that escape the workspace", async () => { + const root = await createTempWorkspace(); + await mkdir(join(root, "subdir"), { recursive: true }); + await writeFile(join(root, "subdir", "file.txt"), "data"); + // Create a symlink inside the workspace that points outside + const outsideDir = await createTempWorkspace("pi-web-move-outside-"); + await symlink(outsideDir, join(root, "subdir", "escape"), "junction"); + + await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow("Path escapes workspace"); + const source = await readWorkspaceFile(root, "subdir/file.txt"); + expect(source.content).toBe("data"); + await expect(readFile(join(outsideDir, "evil.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("prevents moving a source symlink that escapes the workspace", async () => { + const root = await createTempWorkspace(); + const outsideDir = await createTempWorkspace("pi-web-move-source-outside-"); + await writeFile(join(outsideDir, "secret.txt"), "secret"); + await symlink(join(outsideDir, "secret.txt"), join(root, "source-link.txt")); + + await expect(moveWorkspaceFile(root, "source-link.txt", "moved.txt")).rejects.toThrow("Path escapes workspace"); + await expect(readWorkspaceFile(root, "moved.txt")).rejects.toThrow("Path does not exist"); + await expect(readFile(join(outsideDir, "secret.txt"), "utf8")).resolves.toBe("secret"); + }); +}); diff --git a/src/server/workspaces/fileContentService.read.test.ts b/src/server/workspaces/fileContentService.read.test.ts new file mode 100644 index 0000000..3e9080b --- /dev/null +++ b/src/server/workspaces/fileContentService.read.test.ts @@ -0,0 +1,107 @@ +import { mkdir, truncate, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js"; +import { readWorkspaceFile } from "./fileContentService.js"; +import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js"; +import { readWorkspaceImagePreview } from "./imagePreviewService.js"; + +afterEach(async () => { + await cleanupTempWorkspaces(); +}); + +describe("readWorkspaceFile", () => { + it("reads text files with normalized paths and language metadata", async () => { + const root = await createTempWorkspace(); + await mkdir(join(root, "src")); + await writeFile(join(root, "src", "main.ts"), "const answer = 42;\n"); + + const file = await readWorkspaceFile(root, "./src//main.ts"); + + expect(file).toMatchObject({ + path: "src/main.ts", + language: "typescript", + encoding: "utf8", + content: "const answer = 42;\n", + truncated: false, + binary: false, + }); + expect(file.size).toBe(19); + expect(Date.parse(file.modifiedAt)).not.toBeNaN(); + }); + + it("rejects missing paths, directories, traversal, and absolute paths", async () => { + const root = await createTempWorkspace(); + await mkdir(join(root, "dir")); + + await expect(readWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required"); + await expect(readWorkspaceFile(root, "dir")).rejects.toThrow("Path is not a file"); + await expect(readWorkspaceFile(root, "missing.txt")).rejects.toThrow("Path does not exist"); + await expect(readWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed"); + await expect(readWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed"); + }); + + it("reads allowed absolute files outside the workspace", async () => { + const root = await createTempWorkspace(); + const external = await createTempWorkspace(); + await writeFile(join(external, "README.md"), "external docs\n"); + + const file = await readWorkspaceFile(root, join(external, "README.md"), { allowedPaths: [external] }); + + expect(file).toMatchObject({ + path: join(external, "README.md"), + language: "markdown", + content: "external docs\n", + truncated: false, + binary: false, + }); + await expect(readWorkspaceFile(root, join(external, "README.md"))).rejects.toThrow("Absolute paths are not allowed"); + }); + + it("detects binary files and omits binary content", async () => { + const root = await createTempWorkspace(); + await writeFile(join(root, "image.bin"), Buffer.from([0x66, 0x6f, 0x00, 0x6f])); + + const file = await readWorkspaceFile(root, "image.bin"); + + expect(file).toMatchObject({ content: "", binary: true, truncated: false }); + expect(file.size).toBe(4); + }); + + it("marks supported images as previewable", async () => { + const root = await createTempWorkspace(); + await writeFile(join(root, "logo.PNG"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00])); + + const file = await readWorkspaceFile(root, "logo.PNG"); + + expect(file).toMatchObject({ mediaType: "image", mimeType: "image/png", content: "", binary: true, truncated: false }); + expect(file.size).toBe(9); + }); + + it("opens image preview streams only for supported images within the preview size limit", async () => { + const root = await createTempWorkspace(); + await writeFile(join(root, "diagram.svg"), ""); + await writeFile(join(root, "note.txt"), "hello"); + await writeFile(join(root, "huge.png"), ""); + await truncate(join(root, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1); + + const preview = await readWorkspaceImagePreview(root, "diagram.svg"); + preview.stream.destroy(); + + expect(preview).toMatchObject({ path: "diagram.svg", mimeType: "image/svg+xml", size: 46 }); + await expect(readWorkspaceImagePreview(root, "note.txt")).rejects.toThrow("Image preview is not supported"); + await expect(readWorkspaceImagePreview(root, "huge.png")).rejects.toThrow("Image is too large to preview"); + }); + + it("truncates large text files", async () => { + const root = await createTempWorkspace(); + await writeFile(join(root, "large.md"), "a".repeat(512 * 1024 + 7)); + + const file = await readWorkspaceFile(root, "large.md"); + + expect(file.language).toBe("markdown"); + expect(file.content).toHaveLength(512 * 1024); + expect(file.truncated).toBe(true); + expect(file.binary).toBe(false); + }); +}); diff --git a/src/server/workspaces/fileContentService.test.ts b/src/server/workspaces/fileContentService.test.ts deleted file mode 100644 index 580d1a6..0000000 --- a/src/server/workspaces/fileContentService.test.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { mkdtemp, mkdir, readFile, rm, symlink, truncate, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js"; -import { readWorkspaceFile, writeWorkspaceFile } from "./fileContentService.js"; -import { deleteWorkspaceFile, moveWorkspaceFile } from "./fileContentService.js"; -import { readWorkspaceImagePreview } from "./imagePreviewService.js"; - -const roots: string[] = []; - -async function tempWorkspace(): Promise { - const root = await mkdtemp(join(tmpdir(), "pi-web-file-content-")); - roots.push(root); - return root; -} - -afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); -}); - -describe("readWorkspaceFile", () => { - it("reads text files with normalized paths and language metadata", async () => { - const root = await tempWorkspace(); - await mkdir(join(root, "src")); - await writeFile(join(root, "src", "main.ts"), "const answer = 42;\n"); - - const file = await readWorkspaceFile(root, "./src//main.ts"); - - expect(file).toMatchObject({ - path: "src/main.ts", - language: "typescript", - encoding: "utf8", - content: "const answer = 42;\n", - truncated: false, - binary: false, - }); - expect(file.size).toBe(19); - expect(Date.parse(file.modifiedAt)).not.toBeNaN(); - }); - - it("rejects missing paths, directories, traversal, and absolute paths", async () => { - const root = await tempWorkspace(); - await mkdir(join(root, "dir")); - - await expect(readWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required"); - await expect(readWorkspaceFile(root, "dir")).rejects.toThrow("Path is not a file"); - await expect(readWorkspaceFile(root, "missing.txt")).rejects.toThrow("Path does not exist"); - await expect(readWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed"); - await expect(readWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed"); - }); - - it("reads allowed absolute files outside the workspace", async () => { - const root = await tempWorkspace(); - const external = await tempWorkspace(); - await writeFile(join(external, "README.md"), "external docs\n"); - - const file = await readWorkspaceFile(root, join(external, "README.md"), { allowedPaths: [external] }); - - expect(file).toMatchObject({ - path: join(external, "README.md"), - language: "markdown", - content: "external docs\n", - truncated: false, - binary: false, - }); - await expect(readWorkspaceFile(root, join(external, "README.md"))).rejects.toThrow("Absolute paths are not allowed"); - }); - - it("detects binary files and omits binary content", async () => { - const root = await tempWorkspace(); - await writeFile(join(root, "image.bin"), Buffer.from([0x66, 0x6f, 0x00, 0x6f])); - - const file = await readWorkspaceFile(root, "image.bin"); - - expect(file).toMatchObject({ content: "", binary: true, truncated: false }); - expect(file.size).toBe(4); - }); - - it("marks supported images as previewable", async () => { - const root = await tempWorkspace(); - await writeFile(join(root, "logo.PNG"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00])); - - const file = await readWorkspaceFile(root, "logo.PNG"); - - expect(file).toMatchObject({ mediaType: "image", mimeType: "image/png", content: "", binary: true, truncated: false }); - expect(file.size).toBe(9); - }); - - it("opens image preview streams only for supported images within the preview size limit", async () => { - const root = await tempWorkspace(); - await writeFile(join(root, "diagram.svg"), ""); - await writeFile(join(root, "note.txt"), "hello"); - await writeFile(join(root, "huge.png"), ""); - await truncate(join(root, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1); - - const preview = await readWorkspaceImagePreview(root, "diagram.svg"); - preview.stream.destroy(); - - expect(preview).toMatchObject({ path: "diagram.svg", mimeType: "image/svg+xml", size: 46 }); - await expect(readWorkspaceImagePreview(root, "note.txt")).rejects.toThrow("Image preview is not supported"); - await expect(readWorkspaceImagePreview(root, "huge.png")).rejects.toThrow("Image is too large to preview"); - }); - - it("truncates large text files", async () => { - const root = await tempWorkspace(); - await writeFile(join(root, "large.md"), "a".repeat(512 * 1024 + 7)); - - const file = await readWorkspaceFile(root, "large.md"); - - expect(file.language).toBe("markdown"); - expect(file.content).toHaveLength(512 * 1024); - expect(file.truncated).toBe(true); - expect(file.binary).toBe(false); - }); -}); - -describe("writeWorkspaceFile", () => { - it("writes text content to a new file with normalized paths", async () => { - const root = await tempWorkspace(); - - const result = await writeWorkspaceFile(root, "./src//hello.ts", Buffer.from("const greeting = 'hello';\n")); - - expect(result).toMatchObject({ path: "src/hello.ts", created: true }); - expect(result.size).toBe(26); - expect(Date.parse(result.modifiedAt)).not.toBeNaN(); - - // Verify the file was actually written - const content = await readFile(join(root, "src", "hello.ts"), "utf8"); - expect(content).toBe("const greeting = 'hello';\n"); - }); - - it("writes binary content without text re-encoding", async () => { - const root = await tempWorkspace(); - const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); - - const result = await writeWorkspaceFile(root, "image.png", binaryData); - - expect(result).toMatchObject({ path: "image.png", created: true, size: 6 }); - await expect(readFile(join(root, "image.png"))).resolves.toEqual(binaryData); - }); - - it("overwrites existing files by default", async () => { - const root = await tempWorkspace(); - await writeFile(join(root, "notes.txt"), "old content"); - - const result = await writeWorkspaceFile(root, "notes.txt", Buffer.from("new content")); - - expect(result).toMatchObject({ path: "notes.txt", created: false, size: 11 }); - const content = await readFile(join(root, "notes.txt"), "utf8"); - expect(content).toBe("new content"); - }); - - it("throws when overwrite is false and file exists", async () => { - const root = await tempWorkspace(); - await writeFile(join(root, "existing.txt"), "data"); - - await expect(writeWorkspaceFile(root, "existing.txt", Buffer.from("new"), { overwrite: false })).rejects.toThrow("File already exists"); - }); - - it("creates intermediate directories by default", async () => { - const root = await tempWorkspace(); - - await writeWorkspaceFile(root, "deep/nested/dir/file.txt", Buffer.from("deep content")); - - const content = await readFile(join(root, "deep", "nested", "dir", "file.txt"), "utf8"); - expect(content).toBe("deep content"); - }); - - it("fails when createDirs is false and parent directory does not exist", async () => { - const root = await tempWorkspace(); - - await expect(writeWorkspaceFile(root, "missing/dir/file.txt", Buffer.from("x"), { createDirs: false })).rejects.toThrow(); - }); - - it("rejects missing paths, traversal, and absolute paths", async () => { - const root = await tempWorkspace(); - - await expect(writeWorkspaceFile(root, undefined, Buffer.from("x"))).rejects.toThrow("path query parameter is required"); - await expect(writeWorkspaceFile(root, "../secret.txt", Buffer.from("x"))).rejects.toThrow("Path traversal is not allowed"); - await expect(writeWorkspaceFile(root, "/etc/passwd", Buffer.from("x"))).rejects.toThrow("Absolute paths are not allowed"); - }); - - it("rejects writing to a directory path", async () => { - const root = await tempWorkspace(); - await mkdir(join(root, "mydir"), { recursive: true }); - - await expect(writeWorkspaceFile(root, "mydir", Buffer.from("data"))).rejects.toThrow("Path is not a file"); - }); - - it("prevents writing through symlinks that escape the workspace", async () => { - const root = await tempWorkspace(); - await mkdir(join(root, "subdir"), { recursive: true }); - const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-")); - roots.push(outsideDir); - await symlink(outsideDir, join(root, "subdir", "escape"), "junction"); - - await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow("Path escapes workspace"); - await expect(readFile(join(outsideDir, "evil.txt"))).rejects.toMatchObject({ code: "ENOENT" }); - }); -}); - -describe("deleteWorkspaceFile", () => { - it("deletes an existing file and returns existed: true", async () => { - const root = await tempWorkspace(); - await writeFile(join(root, "notes.txt"), "hello"); - - const result = await deleteWorkspaceFile(root, "notes.txt"); - - expect(result).toMatchObject({ path: "notes.txt", existed: true }); - await expect(readWorkspaceFile(root, "notes.txt")).rejects.toThrow("Path does not exist"); - }); - - it("returns existed: false when deleting a non-existent file", async () => { - const root = await tempWorkspace(); - - const result = await deleteWorkspaceFile(root, "missing.txt"); - - expect(result).toMatchObject({ path: "missing.txt", existed: false }); - }); - - it("rejects deleting a directory", async () => { - const root = await tempWorkspace(); - await mkdir(join(root, "mydir"), { recursive: true }); - - await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory"); - }); - - it("rejects traversal and absolute paths", async () => { - const root = await tempWorkspace(); - - await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed"); - await expect(deleteWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed"); - }); - - it("rejects missing path", async () => { - const root = await tempWorkspace(); - - await expect(deleteWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required"); - await expect(deleteWorkspaceFile(root, "")).rejects.toThrow("path query parameter is required"); - }); - - it("deletes a symlink itself, not its target", async () => { - const root = await tempWorkspace(); - const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-")); - roots.push(outsideDir); - await writeFile(join(outsideDir, "real.txt"), "real content"); - // Create a symlink inside the workspace pointing outside - await symlink(join(outsideDir, "real.txt"), join(root, "link.txt")); - - const result = await deleteWorkspaceFile(root, "link.txt"); - - expect(result).toMatchObject({ path: "link.txt", existed: true }); - // The symlink should be gone, but the target file should still exist - await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow("Path does not exist"); - const realContent = await readFile(join(outsideDir, "real.txt"), "utf8"); - expect(realContent).toBe("real content"); - }); - - it("prevents deleting through a symlinked parent directory that escapes the workspace", async () => { - const root = await tempWorkspace(); - await mkdir(join(root, "subdir"), { recursive: true }); - // A real file living outside the workspace that must not be deletable. - const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-parent-")); - roots.push(outsideDir); - await writeFile(join(outsideDir, "victim.txt"), "important"); - // A symlinked parent directory inside the workspace pointing outside. - await symlink(outsideDir, join(root, "subdir", "escape"), "junction"); - - await expect(deleteWorkspaceFile(root, "subdir/escape/victim.txt")).rejects.toThrow("Path escapes workspace"); - // The outside file must survive. - const realContent = await readFile(join(outsideDir, "victim.txt"), "utf8"); - expect(realContent).toBe("important"); - }); -}); - -describe("moveWorkspaceFile", () => { - it("moves a file to a new path", async () => { - const root = await tempWorkspace(); - await writeFile(join(root, "original.txt"), "content"); - - const result = await moveWorkspaceFile(root, "original.txt", "moved.txt"); - - expect(result).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" }); - expect(result.size).toBe(7); - expect(Date.parse(result.modifiedAt)).not.toBeNaN(); - // Source should no longer exist - await expect(readWorkspaceFile(root, "original.txt")).rejects.toThrow("Path does not exist"); - // Target should exist - const target = await readWorkspaceFile(root, "moved.txt"); - expect(target.content).toBe("content"); - }); - - it("creates intermediate directories by default", async () => { - const root = await tempWorkspace(); - await writeFile(join(root, "file.txt"), "data"); - - await moveWorkspaceFile(root, "file.txt", "deep/nested/dir/file.txt"); - - const target = await readWorkspaceFile(root, "deep/nested/dir/file.txt"); - expect(target.content).toBe("data"); - }); - - it("fails when createDirs is false and parent directory does not exist", async () => { - const root = await tempWorkspace(); - await writeFile(join(root, "file.txt"), "data"); - - await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow(); - const source = await readWorkspaceFile(root, "file.txt"); - expect(source.content).toBe("data"); - }); - - it("overwrites target when overwrite is true", async () => { - const root = await tempWorkspace(); - await writeFile(join(root, "source.txt"), "source content"); - await writeFile(join(root, "target.txt"), "target content"); - - const result = await moveWorkspaceFile(root, "source.txt", "target.txt", { overwrite: true }); - - expect(result.toPath).toBe("target.txt"); - const target = await readWorkspaceFile(root, "target.txt"); - expect(target.content).toBe("source content"); - }); - - it("throws when target exists and overwrite is false (default)", async () => { - const root = await tempWorkspace(); - await writeFile(join(root, "source.txt"), "source"); - await writeFile(join(root, "target.txt"), "target"); - - await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists"); - // Source and target should remain unchanged - const source = await readWorkspaceFile(root, "source.txt"); - expect(source.content).toBe("source"); - const target = await readWorkspaceFile(root, "target.txt"); - expect(target.content).toBe("target"); - }); - - it("rejects source path traversal", async () => { - const root = await tempWorkspace(); - - await expect(moveWorkspaceFile(root, "../secret.txt", "target.txt")).rejects.toThrow("Path traversal is not allowed"); - }); - - it("rejects target path traversal", async () => { - const root = await tempWorkspace(); - await writeFile(join(root, "source.txt"), "data"); - - await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow("Path traversal is not allowed"); - const source = await readWorkspaceFile(root, "source.txt"); - expect(source.content).toBe("data"); - }); - - it("rejects moving a directory", async () => { - const root = await tempWorkspace(); - await mkdir(join(root, "mydir"), { recursive: true }); - - await expect(moveWorkspaceFile(root, "mydir", "newdir")).rejects.toThrow("Source path is not a file"); - }); - - it("rejects missing fromPath or toPath", async () => { - const root = await tempWorkspace(); - - await expect(moveWorkspaceFile(root, undefined, "target.txt")).rejects.toThrow("fromPath query parameter is required"); - await expect(moveWorkspaceFile(root, "source.txt", undefined)).rejects.toThrow("toPath query parameter is required"); - await expect(moveWorkspaceFile(root, "", "target.txt")).rejects.toThrow("fromPath query parameter is required"); - await expect(moveWorkspaceFile(root, "source.txt", "")).rejects.toThrow("toPath query parameter is required"); - }); - - it("prevents moving through symlinks that escape the workspace", async () => { - const root = await tempWorkspace(); - await mkdir(join(root, "subdir"), { recursive: true }); - await writeFile(join(root, "subdir", "file.txt"), "data"); - // Create a symlink inside the workspace that points outside - const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-move-outside-")); - roots.push(outsideDir); - await symlink(outsideDir, join(root, "subdir", "escape"), "junction"); - - await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow("Path escapes workspace"); - const source = await readWorkspaceFile(root, "subdir/file.txt"); - expect(source.content).toBe("data"); - await expect(readFile(join(outsideDir, "evil.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("prevents moving a source symlink that escapes the workspace", async () => { - const root = await tempWorkspace(); - const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-move-source-outside-")); - roots.push(outsideDir); - await writeFile(join(outsideDir, "secret.txt"), "secret"); - await symlink(join(outsideDir, "secret.txt"), join(root, "source-link.txt")); - - await expect(moveWorkspaceFile(root, "source-link.txt", "moved.txt")).rejects.toThrow("Path escapes workspace"); - await expect(readWorkspaceFile(root, "moved.txt")).rejects.toThrow("Path does not exist"); - await expect(readFile(join(outsideDir, "secret.txt"), "utf8")).resolves.toBe("secret"); - }); -}); diff --git a/src/server/workspaces/fileContentService.testSupport.ts b/src/server/workspaces/fileContentService.testSupport.ts new file mode 100644 index 0000000..125f966 --- /dev/null +++ b/src/server/workspaces/fileContentService.testSupport.ts @@ -0,0 +1,15 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const tempRoots: string[] = []; + +export async function createTempWorkspace(prefix = "pi-web-file-content-"): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + tempRoots.push(root); + return root; +} + +export async function cleanupTempWorkspaces(): Promise { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +} diff --git a/src/server/workspaces/fileContentService.write.test.ts b/src/server/workspaces/fileContentService.write.test.ts new file mode 100644 index 0000000..1f9c426 --- /dev/null +++ b/src/server/workspaces/fileContentService.write.test.ts @@ -0,0 +1,93 @@ +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { writeWorkspaceFile } from "./fileContentService.js"; +import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js"; + +afterEach(async () => { + await cleanupTempWorkspaces(); +}); + +describe("writeWorkspaceFile", () => { + it("writes text content to a new file with normalized paths", async () => { + const root = await createTempWorkspace(); + + const result = await writeWorkspaceFile(root, "./src//hello.ts", Buffer.from("const greeting = 'hello';\n")); + + expect(result).toMatchObject({ path: "src/hello.ts", created: true }); + expect(result.size).toBe(26); + expect(Date.parse(result.modifiedAt)).not.toBeNaN(); + + // Verify the file was actually written + const content = await readFile(join(root, "src", "hello.ts"), "utf8"); + expect(content).toBe("const greeting = 'hello';\n"); + }); + + it("writes binary content without text re-encoding", async () => { + const root = await createTempWorkspace(); + const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); + + const result = await writeWorkspaceFile(root, "image.png", binaryData); + + expect(result).toMatchObject({ path: "image.png", created: true, size: 6 }); + await expect(readFile(join(root, "image.png"))).resolves.toEqual(binaryData); + }); + + it("overwrites existing files by default", async () => { + const root = await createTempWorkspace(); + await writeFile(join(root, "notes.txt"), "old content"); + + const result = await writeWorkspaceFile(root, "notes.txt", Buffer.from("new content")); + + expect(result).toMatchObject({ path: "notes.txt", created: false, size: 11 }); + const content = await readFile(join(root, "notes.txt"), "utf8"); + expect(content).toBe("new content"); + }); + + it("throws when overwrite is false and file exists", async () => { + const root = await createTempWorkspace(); + await writeFile(join(root, "existing.txt"), "data"); + + await expect(writeWorkspaceFile(root, "existing.txt", Buffer.from("new"), { overwrite: false })).rejects.toThrow("File already exists"); + }); + + it("creates intermediate directories by default", async () => { + const root = await createTempWorkspace(); + + await writeWorkspaceFile(root, "deep/nested/dir/file.txt", Buffer.from("deep content")); + + const content = await readFile(join(root, "deep", "nested", "dir", "file.txt"), "utf8"); + expect(content).toBe("deep content"); + }); + + it("fails when createDirs is false and parent directory does not exist", async () => { + const root = await createTempWorkspace(); + + await expect(writeWorkspaceFile(root, "missing/dir/file.txt", Buffer.from("x"), { createDirs: false })).rejects.toThrow(); + }); + + it("rejects missing paths, traversal, and absolute paths", async () => { + const root = await createTempWorkspace(); + + await expect(writeWorkspaceFile(root, undefined, Buffer.from("x"))).rejects.toThrow("path query parameter is required"); + await expect(writeWorkspaceFile(root, "../secret.txt", Buffer.from("x"))).rejects.toThrow("Path traversal is not allowed"); + await expect(writeWorkspaceFile(root, "/etc/passwd", Buffer.from("x"))).rejects.toThrow("Absolute paths are not allowed"); + }); + + it("rejects writing to a directory path", async () => { + const root = await createTempWorkspace(); + await mkdir(join(root, "mydir"), { recursive: true }); + + await expect(writeWorkspaceFile(root, "mydir", Buffer.from("data"))).rejects.toThrow("Path is not a file"); + }); + + it("prevents writing through symlinks that escape the workspace", async () => { + const root = await createTempWorkspace(); + await mkdir(join(root, "subdir"), { recursive: true }); + const outsideDir = await createTempWorkspace("pi-web-outside-"); + await symlink(outsideDir, join(root, "subdir", "escape"), "junction"); + + await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow("Path escapes workspace"); + await expect(readFile(join(outsideDir, "evil.txt"))).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); From 45bcc63544cdfef68557dc99bf5c6d888c5ad233 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 4 Jul 2026 23:35:11 +0200 Subject: [PATCH 056/111] test(settings): split settings dialog specs --- .../components/SettingsDialog.general.test.ts | 182 +++++ .../SettingsDialog.packages.test.ts | 79 ++ .../components/SettingsDialog.plugins.test.ts | 174 +++++ .../SettingsDialog.sessiond.test.ts | 146 ++++ .../src/components/SettingsDialog.test.ts | 705 ------------------ .../components/SettingsDialog.testSupport.ts | 153 ++++ 6 files changed, 734 insertions(+), 705 deletions(-) create mode 100644 src/client/src/components/SettingsDialog.general.test.ts create mode 100644 src/client/src/components/SettingsDialog.packages.test.ts create mode 100644 src/client/src/components/SettingsDialog.plugins.test.ts create mode 100644 src/client/src/components/SettingsDialog.sessiond.test.ts delete mode 100644 src/client/src/components/SettingsDialog.test.ts create mode 100644 src/client/src/components/SettingsDialog.testSupport.ts diff --git a/src/client/src/components/SettingsDialog.general.test.ts b/src/client/src/components/SettingsDialog.general.test.ts new file mode 100644 index 0000000..7ca56c5 --- /dev/null +++ b/src/client/src/components/SettingsDialog.general.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { configApi, type PiWebConfigResponse } from "../api"; +import { SettingsDialog } from "./SettingsDialog"; +import { callDialogPromise, callDialogUpdated, collectTemplateStrings, configResponse, deferred, getDialogProperty, remoteMachine, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("settings-dialog general settings machine targeting", () => { + it("renders the active settings panel without the old global scope note", () => { + const dialog = new SettingsDialog(); + dialog.section = "general"; + dialog.machine = remoteMachine; + + const strings = collectTemplateStrings(dialog.render()).join(""); + + expect(strings).toContain(" { + stubWindowTimers(); + const savedConfig = configResponse({ host: "0.0.0.0", port: 9000, allowedHosts: true }); + const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig); + const onConfigSaved = vi.fn(); + const dialog = new SettingsDialog(); + dialog.onConfigSaved = onConfigSaved; + + await callDialogPromise(dialog, "saveConfig", { host: "0.0.0.0", port: 9000, allowedHosts: true }); + + expect(saveSpy.mock.calls).toEqual([[{ host: "0.0.0.0", port: 9000, allowedHosts: true }]]); + expect(getDialogProperty(dialog, "configResponse")).toBe(savedConfig); + expect(onConfigSaved).toHaveBeenCalledWith({ host: "0.0.0.0", port: 9000, allowedHosts: true }); + expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved."); + expect(getDialogProperty(dialog, "saving")).toBe(false); + }); + + it("loads file access and upload config from the selected machine", async () => { + const config = configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual/uploads" } }); + const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + + await callDialogPromise(dialog, "loadAccessConfigForTarget"); + + expect(configSpy.mock.calls).toEqual([["remote-a"]]); + expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(config); + expect(getDialogProperty(dialog, "accessError")).toBe(""); + expect(getDialogProperty(dialog, "accessLoading")).toBe(false); + }); + + it("saves selected-machine file access and upload config through the selected-machine endpoint", async () => { + stubWindowTimers(); + const patch = { pathAccess: { allowedPaths: ["/mnt/share", "~/SDKs"] }, uploads: { defaultFolder: "manual/uploads" } }; + const savedConfig = configResponse(patch); + const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + + await callDialogPromise(dialog, "saveMachineAccessConfig", patch); + + expect(saveSpy.mock.calls).toEqual([[patch, "remote-a"]]); + expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig); + expect(getDialogProperty(dialog, "configResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved."); + expect(getDialogProperty(dialog, "saving")).toBe(false); + }); + + it("merges local selected-machine access saves into gateway config without dropping gateway-only values", async () => { + stubWindowTimers(); + const gatewayConfig = configResponse({ + host: "127.0.0.1", + port: 8504, + allowedHosts: ["gateway.local"], + shortcuts: { "core:view.chat": "mod+1" }, + plugins: { info: { enabled: true } }, + spawnSessions: false, + pathAccess: { allowedPaths: ["/old"] }, + uploads: { defaultFolder: "old/uploads" }, + maxUploadBytes: 1234, + }); + const patch = { pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {} }; + const savedConfig = configResponse({ pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {}, maxUploadBytes: 5678 }); + const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig); + const onConfigSaved = vi.fn(); + const dialog = new SettingsDialog(); + dialog.onConfigSaved = onConfigSaved; + setDialogProperty(dialog, "configResponse", gatewayConfig); + + await callDialogPromise(dialog, "saveMachineAccessConfig", patch); + + expect(saveSpy.mock.calls).toEqual([[patch, "local"]]); + expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig); + expect(getDialogProperty(dialog, "configResponse")).toMatchObject({ + config: { + host: "127.0.0.1", + port: 8504, + allowedHosts: ["gateway.local"], + shortcuts: { "core:view.chat": "mod+1" }, + plugins: { info: { enabled: true } }, + spawnSessions: false, + pathAccess: { allowedPaths: ["~/SDKs"] }, + uploads: {}, + maxUploadBytes: 5678, + }, + effectiveConfig: { + host: "127.0.0.1", + port: 8504, + allowedHosts: ["gateway.local"], + shortcuts: { "core:view.chat": "mod+1" }, + plugins: { info: { enabled: true } }, + spawnSessions: false, + pathAccess: { allowedPaths: ["~/SDKs"] }, + uploads: {}, + maxUploadBytes: 5678, + }, + }); + expect(onConfigSaved).toHaveBeenCalledWith({ + host: "127.0.0.1", + port: 8504, + allowedHosts: ["gateway.local"], + shortcuts: { "core:view.chat": "mod+1" }, + plugins: { info: { enabled: true } }, + spawnSessions: false, + pathAccess: { allowedPaths: ["~/SDKs"] }, + uploads: {}, + maxUploadBytes: 5678, + }); + }); + + it("ignores stale file access load responses after the selected machine changes", async () => { + const load = deferred(); + vi.spyOn(configApi, "config").mockReturnValue(load.promise); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + + const loadPromise = callDialogPromise(dialog, "loadAccessConfigForTarget"); + expect(getDialogProperty(dialog, "accessLoading")).toBe(true); + + dialog.machine = secondRemoteMachine; + callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); + load.resolve(configResponse({ pathAccess: { allowedPaths: ["/stale"] } })); + await loadPromise; + + expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "accessError")).toBe(""); + expect(getDialogProperty(dialog, "accessLoading")).toBe(false); + }); + + it("ignores stale file access save responses after the selected machine changes", async () => { + const save = deferred(); + vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + + const savePromise = callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } }); + expect(getDialogProperty(dialog, "saving")).toBe(true); + + dialog.machine = secondRemoteMachine; + callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); + save.resolve(configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } })); + await savePromise; + + expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "savedMessage")).toBe(""); + expect(getDialogProperty(dialog, "saving")).toBe(false); + }); + + it("shows selected-machine file access errors with the selected target name", async () => { + vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable")); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + + await callDialogPromise(dialog, "loadAccessConfigForTarget"); + + expect(getDialogProperty(dialog, "accessError")).toBe("Failed to load file access/upload config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again."); + expect(getDialogProperty(dialog, "accessLoading")).toBe(false); + }); +}); diff --git a/src/client/src/components/SettingsDialog.packages.test.ts b/src/client/src/components/SettingsDialog.packages.test.ts new file mode 100644 index 0000000..29017cf --- /dev/null +++ b/src/client/src/components/SettingsDialog.packages.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { piPackagesApi, pluginsApi, type PiPackageMutationResponse } from "../api"; +import { SettingsDialog } from "./SettingsDialog"; +import { callDialogPromise, callDialogUpdated, deferred, getDialogProperty, packageInfo, packageMutationResponse, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement, secondRemoteMachine } from "./SettingsDialog.testSupport"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("settings-dialog Pi package orchestration", () => { + it("loads package data from the selected machine and ignores stale target responses", async () => { + const remotePackages = { packages: [packageInfo("npm:@acme/tools")] }; + const staleLoad = deferred(); + const packagesSpy = vi.spyOn(piPackagesApi, "packages").mockReturnValue(staleLoad.promise); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.machineRuntime = runtimeWithPackageManagement; + + const loadPromise = callDialogPromise(dialog, "loadPackagesForTarget"); + expect(packagesSpy.mock.calls).toEqual([["remote-a"]]); + expect(getDialogProperty(dialog, "packageLoading")).toBe(true); + + dialog.machine = secondRemoteMachine; + callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); + staleLoad.resolve(remotePackages); + await loadPromise; + + expect(getDialogProperty(dialog, "packagesResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "packageError")).toBe(""); + expect(getDialogProperty(dialog, "packageMessage")).toBe(""); + expect(getDialogProperty(dialog, "packageLoading")).toBe(false); + }); + + it("runs remote package mutations against the selected machine without refreshing gateway plugins", async () => { + const installedPackages = [packageInfo("npm:@acme/new-tools")]; + const install = deferred(); + const installSpy = vi.spyOn(piPackagesApi, "install").mockReturnValue(install.promise); + const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("gateway", true)])); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.machineRuntime = runtimeWithPackageManagement; + + const installPromise = callDialogPromise(dialog, "installPiPackage", "npm:@acme/new-tools"); + + expect(installSpy.mock.calls).toEqual([["npm:@acme/new-tools", "remote-a"]]); + expect(getDialogProperty(dialog, "saving")).toBe(true); + expect(getDialogProperty(dialog, "packageOperation")).toEqual({ kind: "install", source: "npm:@acme/new-tools" }); + + install.resolve(packageMutationResponse("install", installedPackages, "npm:@acme/new-tools")); + await installPromise; + + expect(pluginsSpy).not.toHaveBeenCalled(); + expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: installedPackages }); + expect(getDialogProperty(dialog, "packageMessage")).toContain("Pi package installed on Lab Mac"); + expect(getDialogProperty(dialog, "packageMessage")).toContain("each idle PI WEB session on Lab Mac"); + expect(getDialogProperty(dialog, "packageError")).toBe(""); + expect(getDialogProperty(dialog, "packageOperation")).toBeUndefined(); + expect(getDialogProperty(dialog, "saving")).toBe(false); + }); + + it("refreshes gateway plugins after a local package mutation", async () => { + const updatedPackages = [packageInfo("npm:@acme/tools")]; + const refreshedPlugins = pluginsResponse([pluginInfo("browser-helper", true)]); + const updateSpy = vi.spyOn(piPackagesApi, "update").mockResolvedValue(packageMutationResponse("update", updatedPackages)); + const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins); + const dialog = new SettingsDialog(); + + await callDialogPromise(dialog, "updatePiPackage"); + + expect(updateSpy.mock.calls).toEqual([[undefined, "local"]]); + expect(pluginsSpy.mock.calls).toEqual([[]]); + expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: updatedPackages }); + expect(getDialogProperty(dialog, "pluginsResponse")).toBe(refreshedPlugins); + expect(getDialogProperty(dialog, "packageMessage")).toContain("Reload the browser page separately for PI WEB browser plugin changes"); + expect(getDialogProperty(dialog, "packageError")).toBe(""); + expect(getDialogProperty(dialog, "saving")).toBe(false); + }); +}); diff --git a/src/client/src/components/SettingsDialog.plugins.test.ts b/src/client/src/components/SettingsDialog.plugins.test.ts new file mode 100644 index 0000000..8aae22f --- /dev/null +++ b/src/client/src/components/SettingsDialog.plugins.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api"; +import { SettingsDialog } from "./SettingsDialog"; +import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("settings-dialog plugin settings machine targeting", () => { + it("loads plugin config and plugin list from the selected machine", async () => { + const config = configResponse({ plugins: { info: { enabled: true } } }); + const plugins = pluginsResponse([pluginInfo("info", true)]); + const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config); + const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + + await callDialogPromise(dialog, "loadPluginsForTarget"); + + expect(configSpy.mock.calls).toEqual([["remote-a"]]); + expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]); + expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config); + expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(plugins); + expect(getDialogProperty(dialog, "pluginError")).toBe(""); + expect(getDialogProperty(dialog, "pluginLoading")).toBe(false); + }); + + it("keeps fulfilled plugin config when the selected machine plugin list is unsupported", async () => { + const config = configResponse({ plugins: { info: { enabled: true } } }); + vi.spyOn(configApi, "config").mockResolvedValue(config); + vi.spyOn(pluginsApi, "plugins").mockRejectedValue(new Error("route GET:/api/plugins not found")); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + + await callDialogPromise(dialog, "loadPluginsForTarget"); + + expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config); + expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "pluginError")).toBe("Failed to load PI WEB plugin settings from Lab Mac (remote machine): PI WEB plugins: Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); + expect(getDialogProperty(dialog, "pluginLoading")).toBe(false); + }); + + it("saves selected-machine plugin toggles as plugin-only patches and refreshes the selected machine plugin list", async () => { + stubWindowTimers(); + const baseConfig = configResponse({ + plugins: { + keep: { enabled: true, settings: { level: 1 } }, + info: { settings: { color: "blue" } }, + }, + }); + const savedConfig = configResponse({ + plugins: { + keep: { enabled: true, settings: { level: 1 } }, + info: { enabled: false, settings: { color: "blue" } }, + }, + }); + const refreshedPlugins = pluginsResponse([pluginInfo("info", false), pluginInfo("keep", true)]); + const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig); + const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + setDialogProperty(dialog, "selectedPluginConfigResponse", baseConfig); + + await callDialogPromise(dialog, "togglePlugin", "info", false); + + expect(saveSpy.mock.calls).toEqual([[ + { + plugins: { + keep: { enabled: true, settings: { level: 1 } }, + info: { enabled: false, settings: { color: "blue" } }, + }, + }, + "remote-a", + ]]); + expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]); + expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig); + expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins); + expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved."); + expect(getDialogProperty(dialog, "saving")).toBe(false); + }); + + it("merges local selected-machine plugin saves into gateway config without dropping gateway-only values", async () => { + stubWindowTimers(); + const gatewayConfig = configResponse({ + host: "127.0.0.1", + shortcuts: { "core:view.chat": "mod+1" }, + spawnSessions: false, + plugins: { info: { enabled: false }, gateway: { settings: { theme: "dark" } } }, + }); + const savedConfig = configResponse({ plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } } }); + const refreshedPlugins = pluginsResponse([pluginInfo("info", true)]); + const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig); + vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins); + const onConfigSaved = vi.fn(); + const dialog = new SettingsDialog(); + dialog.onConfigSaved = onConfigSaved; + setDialogProperty(dialog, "configResponse", gatewayConfig); + setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: false } } })); + + await callDialogPromise(dialog, "togglePlugin", "info", true); + + expect(saveSpy.mock.calls).toEqual([[{ plugins: { info: { enabled: true } } }, "local"]]); + expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig); + expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins); + expect(getDialogProperty(dialog, "configResponse")).toMatchObject({ + config: { + host: "127.0.0.1", + shortcuts: { "core:view.chat": "mod+1" }, + spawnSessions: false, + plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } }, + }, + effectiveConfig: { + host: "127.0.0.1", + shortcuts: { "core:view.chat": "mod+1" }, + spawnSessions: false, + plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } }, + }, + }); + expect(onConfigSaved).toHaveBeenCalledWith({ + host: "127.0.0.1", + shortcuts: { "core:view.chat": "mod+1" }, + spawnSessions: false, + plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } }, + }); + }); + + it("ignores stale plugin load responses after the selected machine changes", async () => { + const configLoad = deferred(); + const pluginsLoad = deferred(); + vi.spyOn(configApi, "config").mockReturnValue(configLoad.promise); + vi.spyOn(pluginsApi, "plugins").mockReturnValue(pluginsLoad.promise); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + + const loadPromise = callDialogPromise(dialog, "loadPluginsForTarget"); + expect(getDialogProperty(dialog, "pluginLoading")).toBe(true); + + dialog.machine = secondRemoteMachine; + callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); + configLoad.resolve(configResponse({ plugins: { info: { enabled: true } } })); + pluginsLoad.resolve(pluginsResponse([pluginInfo("info", true)])); + await loadPromise; + + expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "pluginError")).toBe(""); + expect(getDialogProperty(dialog, "pluginLoading")).toBe(false); + }); + + it("ignores stale plugin save responses after the selected machine changes", async () => { + const save = deferred(); + const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", false)])); + vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } })); + + const savePromise = callDialogPromise(dialog, "togglePlugin", "info", false); + expect(getDialogProperty(dialog, "saving")).toBe(true); + + dialog.machine = secondRemoteMachine; + callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); + save.resolve(configResponse({ plugins: { info: { enabled: false } } })); + await savePromise; + + expect(pluginsSpy).not.toHaveBeenCalled(); + expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "savedMessage")).toBe(""); + expect(getDialogProperty(dialog, "saving")).toBe(false); + }); +}); diff --git a/src/client/src/components/SettingsDialog.sessiond.test.ts b/src/client/src/components/SettingsDialog.sessiond.test.ts new file mode 100644 index 0000000..cbd7528 --- /dev/null +++ b/src/client/src/components/SettingsDialog.sessiond.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api"; +import { SettingsDialog } from "./SettingsDialog"; +import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement as runtimeWithoutSelectedMachineSettings, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("settings-dialog session daemon machine targeting", () => { + it("keeps gateway settings loads on the gateway config/plugin endpoints", async () => { + const config = configResponse({ host: "127.0.0.1" }); + const plugins: PiWebPluginsResponse = { plugins: [] }; + const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config); + const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins); + const dialog = new SettingsDialog(); + + await callDialogPromise(dialog, "loadConfig"); + + expect(configSpy.mock.calls).toEqual([[]]); + expect(pluginsSpy.mock.calls).toEqual([[]]); + expect(getDialogProperty(dialog, "configResponse")).toBe(config); + expect(getDialogProperty(dialog, "pluginsResponse")).toBe(plugins); + expect(getDialogProperty(dialog, "error")).toBe(""); + expect(getDialogProperty(dialog, "loading")).toBe(false); + }); + + it("loads session-daemon config from the selected machine", async () => { + const config = configResponse({ spawnSessions: false, subsessions: true }); + const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + + await callDialogPromise(dialog, "loadSessiondConfigForTarget"); + + expect(configSpy.mock.calls).toEqual([["remote-a"]]); + expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config); + expect(getDialogProperty(dialog, "sessiondError")).toBe(""); + expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false); + }); + + it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => { + stubWindowTimers(); + const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false }); + const savedConfig = configResponse({ spawnSessions: true }); + const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig); + const dialog = new SettingsDialog(); + setDialogProperty(dialog, "configResponse", gatewayConfig); + + await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true }); + + expect(saveSpy.mock.calls).toEqual([[{ spawnSessions: true }, "local"]]); + expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(savedConfig); + expect(getDialogProperty(dialog, "configResponse")).toMatchObject({ config: { host: "127.0.0.1", spawnSessions: true, subsessions: false } }); + expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved."); + expect(getDialogProperty(dialog, "saving")).toBe(false); + }); + + it("ignores stale session-daemon load responses after the selected machine changes", async () => { + const load = deferred(); + vi.spyOn(configApi, "config").mockReturnValue(load.promise); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + + const loadPromise = callDialogPromise(dialog, "loadSessiondConfigForTarget"); + expect(getDialogProperty(dialog, "sessiondLoading")).toBe(true); + + dialog.machine = secondRemoteMachine; + callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); + load.resolve(configResponse({ spawnSessions: false })); + await loadPromise; + + expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "sessiondError")).toBe(""); + expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false); + }); + + it("ignores stale session-daemon save responses after the selected machine changes", async () => { + stubWindowTimers(); + const save = deferred(); + vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + + const savePromise = callDialogPromise(dialog, "saveSessiondConfig", { subsessions: true }); + expect(getDialogProperty(dialog, "saving")).toBe(true); + + dialog.machine = secondRemoteMachine; + save.resolve(configResponse({ subsessions: true })); + await savePromise; + + expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "savedMessage")).toBe(""); + expect(getDialogProperty(dialog, "saving")).toBe(false); + }); + + it("skips selected-machine settings loads when the remote runtime does not advertise support", async () => { + const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(configResponse({ spawnSessions: true })); + const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", true)])); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.machineRuntime = runtimeWithoutSelectedMachineSettings; + + await callDialogPromise(dialog, "loadSessiondConfigForTarget"); + await callDialogPromise(dialog, "loadAccessConfigForTarget"); + await callDialogPromise(dialog, "loadPluginsForTarget"); + + expect(configSpy).not.toHaveBeenCalled(); + expect(pluginsSpy).not.toHaveBeenCalled(); + expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); + expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); + expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); + }); + + it("does not save remote selected-machine settings when runtime support is missing", async () => { + const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ spawnSessions: true })); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.machineRuntime = runtimeWithoutSelectedMachineSettings; + setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } })); + + await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true }); + await callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] } }); + await callDialogPromise(dialog, "togglePlugin", "info", false); + + expect(saveSpy).not.toHaveBeenCalled(); + expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); + expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); + expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); + }); + + it("shows selected-machine settings errors with the selected target name", async () => { + vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable")); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + + await callDialogPromise(dialog, "loadSessiondConfigForTarget"); + + expect(getDialogProperty(dialog, "sessiondError")).toBe("Failed to load session-daemon config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again."); + expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false); + }); +}); diff --git a/src/client/src/components/SettingsDialog.test.ts b/src/client/src/components/SettingsDialog.test.ts deleted file mode 100644 index e453492..0000000 --- a/src/client/src/components/SettingsDialog.test.ts +++ /dev/null @@ -1,705 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { TemplateResult } from "lit"; -import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import { configApi, piPackagesApi, pluginsApi, type Machine, type MachineRuntime, type PiPackageInfo, type PiPackageMutationResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginInfo, type PiWebPluginsResponse } from "../api"; -import { SettingsDialog } from "./SettingsDialog"; - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -describe("settings-dialog session daemon machine targeting", () => { - it("keeps gateway settings loads on the gateway config/plugin endpoints", async () => { - const config = configResponse({ host: "127.0.0.1" }); - const plugins: PiWebPluginsResponse = { plugins: [] }; - const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config); - const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins); - const dialog = new SettingsDialog(); - - await callDialogPromise(dialog, "loadConfig"); - - expect(configSpy.mock.calls).toEqual([[]]); - expect(pluginsSpy.mock.calls).toEqual([[]]); - expect(getDialogProperty(dialog, "configResponse")).toBe(config); - expect(getDialogProperty(dialog, "pluginsResponse")).toBe(plugins); - expect(getDialogProperty(dialog, "error")).toBe(""); - expect(getDialogProperty(dialog, "loading")).toBe(false); - }); - - it("loads session-daemon config from the selected machine", async () => { - const config = configResponse({ spawnSessions: false, subsessions: true }); - const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - - await callDialogPromise(dialog, "loadSessiondConfigForTarget"); - - expect(configSpy.mock.calls).toEqual([["remote-a"]]); - expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config); - expect(getDialogProperty(dialog, "sessiondError")).toBe(""); - expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false); - }); - - it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => { - stubWindowTimers(); - const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false }); - const savedConfig = configResponse({ spawnSessions: true }); - const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig); - const dialog = new SettingsDialog(); - setDialogProperty(dialog, "configResponse", gatewayConfig); - - await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true }); - - expect(saveSpy.mock.calls).toEqual([[{ spawnSessions: true }, "local"]]); - expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(savedConfig); - expect(getDialogProperty(dialog, "configResponse")).toMatchObject({ config: { host: "127.0.0.1", spawnSessions: true, subsessions: false } }); - expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved."); - expect(getDialogProperty(dialog, "saving")).toBe(false); - }); - - it("ignores stale session-daemon load responses after the selected machine changes", async () => { - const load = deferred(); - vi.spyOn(configApi, "config").mockReturnValue(load.promise); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - - const loadPromise = callDialogPromise(dialog, "loadSessiondConfigForTarget"); - expect(getDialogProperty(dialog, "sessiondLoading")).toBe(true); - - dialog.machine = secondRemoteMachine; - callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); - load.resolve(configResponse({ spawnSessions: false })); - await loadPromise; - - expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined(); - expect(getDialogProperty(dialog, "sessiondError")).toBe(""); - expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false); - }); - - it("ignores stale session-daemon save responses after the selected machine changes", async () => { - stubWindowTimers(); - const save = deferred(); - vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - - const savePromise = callDialogPromise(dialog, "saveSessiondConfig", { subsessions: true }); - expect(getDialogProperty(dialog, "saving")).toBe(true); - - dialog.machine = secondRemoteMachine; - save.resolve(configResponse({ subsessions: true })); - await savePromise; - - expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined(); - expect(getDialogProperty(dialog, "savedMessage")).toBe(""); - expect(getDialogProperty(dialog, "saving")).toBe(false); - }); - - it("skips selected-machine settings loads when the remote runtime does not advertise support", async () => { - const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(configResponse({ spawnSessions: true })); - const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", true)])); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - dialog.machineRuntime = runtimeWithoutSelectedMachineSettings; - - await callDialogPromise(dialog, "loadSessiondConfigForTarget"); - await callDialogPromise(dialog, "loadAccessConfigForTarget"); - await callDialogPromise(dialog, "loadPluginsForTarget"); - - expect(configSpy).not.toHaveBeenCalled(); - expect(pluginsSpy).not.toHaveBeenCalled(); - expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined(); - expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined(); - expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined(); - expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); - expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); - expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); - }); - - it("does not save remote selected-machine settings when runtime support is missing", async () => { - const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ spawnSessions: true })); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - dialog.machineRuntime = runtimeWithoutSelectedMachineSettings; - setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } })); - - await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true }); - await callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] } }); - await callDialogPromise(dialog, "togglePlugin", "info", false); - - expect(saveSpy).not.toHaveBeenCalled(); - expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); - expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); - expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); - }); - - it("shows selected-machine settings errors with the selected target name", async () => { - vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable")); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - - await callDialogPromise(dialog, "loadSessiondConfigForTarget"); - - expect(getDialogProperty(dialog, "sessiondError")).toBe("Failed to load session-daemon config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again."); - expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false); - }); -}); - -describe("settings-dialog general settings machine targeting", () => { - it("renders the active settings panel without the old global scope note", () => { - const dialog = new SettingsDialog(); - dialog.section = "general"; - dialog.machine = remoteMachine; - - const strings = collectTemplateStrings(dialog.render()).join(""); - - expect(strings).toContain(" { - stubWindowTimers(); - const savedConfig = configResponse({ host: "0.0.0.0", port: 9000, allowedHosts: true }); - const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig); - const onConfigSaved = vi.fn(); - const dialog = new SettingsDialog(); - dialog.onConfigSaved = onConfigSaved; - - await callDialogPromise(dialog, "saveConfig", { host: "0.0.0.0", port: 9000, allowedHosts: true }); - - expect(saveSpy.mock.calls).toEqual([[{ host: "0.0.0.0", port: 9000, allowedHosts: true }]]); - expect(getDialogProperty(dialog, "configResponse")).toBe(savedConfig); - expect(onConfigSaved).toHaveBeenCalledWith({ host: "0.0.0.0", port: 9000, allowedHosts: true }); - expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved."); - expect(getDialogProperty(dialog, "saving")).toBe(false); - }); - - it("loads file access and upload config from the selected machine", async () => { - const config = configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual/uploads" } }); - const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - - await callDialogPromise(dialog, "loadAccessConfigForTarget"); - - expect(configSpy.mock.calls).toEqual([["remote-a"]]); - expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(config); - expect(getDialogProperty(dialog, "accessError")).toBe(""); - expect(getDialogProperty(dialog, "accessLoading")).toBe(false); - }); - - it("saves selected-machine file access and upload config through the selected-machine endpoint", async () => { - stubWindowTimers(); - const patch = { pathAccess: { allowedPaths: ["/mnt/share", "~/SDKs"] }, uploads: { defaultFolder: "manual/uploads" } }; - const savedConfig = configResponse(patch); - const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - - await callDialogPromise(dialog, "saveMachineAccessConfig", patch); - - expect(saveSpy.mock.calls).toEqual([[patch, "remote-a"]]); - expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig); - expect(getDialogProperty(dialog, "configResponse")).toBeUndefined(); - expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved."); - expect(getDialogProperty(dialog, "saving")).toBe(false); - }); - - it("merges local selected-machine access saves into gateway config without dropping gateway-only values", async () => { - stubWindowTimers(); - const gatewayConfig = configResponse({ - host: "127.0.0.1", - port: 8504, - allowedHosts: ["gateway.local"], - shortcuts: { "core:view.chat": "mod+1" }, - plugins: { info: { enabled: true } }, - spawnSessions: false, - pathAccess: { allowedPaths: ["/old"] }, - uploads: { defaultFolder: "old/uploads" }, - maxUploadBytes: 1234, - }); - const patch = { pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {} }; - const savedConfig = configResponse({ pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {}, maxUploadBytes: 5678 }); - const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig); - const onConfigSaved = vi.fn(); - const dialog = new SettingsDialog(); - dialog.onConfigSaved = onConfigSaved; - setDialogProperty(dialog, "configResponse", gatewayConfig); - - await callDialogPromise(dialog, "saveMachineAccessConfig", patch); - - expect(saveSpy.mock.calls).toEqual([[patch, "local"]]); - expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig); - expect(getDialogProperty(dialog, "configResponse")).toMatchObject({ - config: { - host: "127.0.0.1", - port: 8504, - allowedHosts: ["gateway.local"], - shortcuts: { "core:view.chat": "mod+1" }, - plugins: { info: { enabled: true } }, - spawnSessions: false, - pathAccess: { allowedPaths: ["~/SDKs"] }, - uploads: {}, - maxUploadBytes: 5678, - }, - effectiveConfig: { - host: "127.0.0.1", - port: 8504, - allowedHosts: ["gateway.local"], - shortcuts: { "core:view.chat": "mod+1" }, - plugins: { info: { enabled: true } }, - spawnSessions: false, - pathAccess: { allowedPaths: ["~/SDKs"] }, - uploads: {}, - maxUploadBytes: 5678, - }, - }); - expect(onConfigSaved).toHaveBeenCalledWith({ - host: "127.0.0.1", - port: 8504, - allowedHosts: ["gateway.local"], - shortcuts: { "core:view.chat": "mod+1" }, - plugins: { info: { enabled: true } }, - spawnSessions: false, - pathAccess: { allowedPaths: ["~/SDKs"] }, - uploads: {}, - maxUploadBytes: 5678, - }); - }); - - it("ignores stale file access load responses after the selected machine changes", async () => { - const load = deferred(); - vi.spyOn(configApi, "config").mockReturnValue(load.promise); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - - const loadPromise = callDialogPromise(dialog, "loadAccessConfigForTarget"); - expect(getDialogProperty(dialog, "accessLoading")).toBe(true); - - dialog.machine = secondRemoteMachine; - callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); - load.resolve(configResponse({ pathAccess: { allowedPaths: ["/stale"] } })); - await loadPromise; - - expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined(); - expect(getDialogProperty(dialog, "accessError")).toBe(""); - expect(getDialogProperty(dialog, "accessLoading")).toBe(false); - }); - - it("ignores stale file access save responses after the selected machine changes", async () => { - const save = deferred(); - vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - - const savePromise = callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } }); - expect(getDialogProperty(dialog, "saving")).toBe(true); - - dialog.machine = secondRemoteMachine; - callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); - save.resolve(configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } })); - await savePromise; - - expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined(); - expect(getDialogProperty(dialog, "savedMessage")).toBe(""); - expect(getDialogProperty(dialog, "saving")).toBe(false); - }); - - it("shows selected-machine file access errors with the selected target name", async () => { - vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable")); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - - await callDialogPromise(dialog, "loadAccessConfigForTarget"); - - expect(getDialogProperty(dialog, "accessError")).toBe("Failed to load file access/upload config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again."); - expect(getDialogProperty(dialog, "accessLoading")).toBe(false); - }); -}); - -describe("settings-dialog Pi package orchestration", () => { - it("loads package data from the selected machine and ignores stale target responses", async () => { - const remotePackages = { packages: [packageInfo("npm:@acme/tools")] }; - const staleLoad = deferred(); - const packagesSpy = vi.spyOn(piPackagesApi, "packages").mockReturnValue(staleLoad.promise); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - dialog.machineRuntime = runtimeWithPackageManagement; - - const loadPromise = callDialogPromise(dialog, "loadPackagesForTarget"); - expect(packagesSpy.mock.calls).toEqual([["remote-a"]]); - expect(getDialogProperty(dialog, "packageLoading")).toBe(true); - - dialog.machine = secondRemoteMachine; - callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); - staleLoad.resolve(remotePackages); - await loadPromise; - - expect(getDialogProperty(dialog, "packagesResponse")).toBeUndefined(); - expect(getDialogProperty(dialog, "packageError")).toBe(""); - expect(getDialogProperty(dialog, "packageMessage")).toBe(""); - expect(getDialogProperty(dialog, "packageLoading")).toBe(false); - }); - - it("runs remote package mutations against the selected machine without refreshing gateway plugins", async () => { - const installedPackages = [packageInfo("npm:@acme/new-tools")]; - const install = deferred(); - const installSpy = vi.spyOn(piPackagesApi, "install").mockReturnValue(install.promise); - const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("gateway", true)])); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - dialog.machineRuntime = runtimeWithPackageManagement; - - const installPromise = callDialogPromise(dialog, "installPiPackage", "npm:@acme/new-tools"); - - expect(installSpy.mock.calls).toEqual([["npm:@acme/new-tools", "remote-a"]]); - expect(getDialogProperty(dialog, "saving")).toBe(true); - expect(getDialogProperty(dialog, "packageOperation")).toEqual({ kind: "install", source: "npm:@acme/new-tools" }); - - install.resolve(packageMutationResponse("install", installedPackages, "npm:@acme/new-tools")); - await installPromise; - - expect(pluginsSpy).not.toHaveBeenCalled(); - expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: installedPackages }); - expect(getDialogProperty(dialog, "packageMessage")).toContain("Pi package installed on Lab Mac"); - expect(getDialogProperty(dialog, "packageMessage")).toContain("each idle PI WEB session on Lab Mac"); - expect(getDialogProperty(dialog, "packageError")).toBe(""); - expect(getDialogProperty(dialog, "packageOperation")).toBeUndefined(); - expect(getDialogProperty(dialog, "saving")).toBe(false); - }); - - it("refreshes gateway plugins after a local package mutation", async () => { - const updatedPackages = [packageInfo("npm:@acme/tools")]; - const refreshedPlugins = pluginsResponse([pluginInfo("browser-helper", true)]); - const updateSpy = vi.spyOn(piPackagesApi, "update").mockResolvedValue(packageMutationResponse("update", updatedPackages)); - const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins); - const dialog = new SettingsDialog(); - - await callDialogPromise(dialog, "updatePiPackage"); - - expect(updateSpy.mock.calls).toEqual([[undefined, "local"]]); - expect(pluginsSpy.mock.calls).toEqual([[]]); - expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: updatedPackages }); - expect(getDialogProperty(dialog, "pluginsResponse")).toBe(refreshedPlugins); - expect(getDialogProperty(dialog, "packageMessage")).toContain("Reload the browser page separately for PI WEB browser plugin changes"); - expect(getDialogProperty(dialog, "packageError")).toBe(""); - expect(getDialogProperty(dialog, "saving")).toBe(false); - }); -}); - -describe("settings-dialog plugin settings machine targeting", () => { - it("loads plugin config and plugin list from the selected machine", async () => { - const config = configResponse({ plugins: { info: { enabled: true } } }); - const plugins = pluginsResponse([pluginInfo("info", true)]); - const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config); - const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - - await callDialogPromise(dialog, "loadPluginsForTarget"); - - expect(configSpy.mock.calls).toEqual([["remote-a"]]); - expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]); - expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config); - expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(plugins); - expect(getDialogProperty(dialog, "pluginError")).toBe(""); - expect(getDialogProperty(dialog, "pluginLoading")).toBe(false); - }); - - it("keeps fulfilled plugin config when the selected machine plugin list is unsupported", async () => { - const config = configResponse({ plugins: { info: { enabled: true } } }); - vi.spyOn(configApi, "config").mockResolvedValue(config); - vi.spyOn(pluginsApi, "plugins").mockRejectedValue(new Error("route GET:/api/plugins not found")); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - - await callDialogPromise(dialog, "loadPluginsForTarget"); - - expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config); - expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined(); - expect(getDialogProperty(dialog, "pluginError")).toBe("Failed to load PI WEB plugin settings from Lab Mac (remote machine): PI WEB plugins: Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); - expect(getDialogProperty(dialog, "pluginLoading")).toBe(false); - }); - - it("saves selected-machine plugin toggles as plugin-only patches and refreshes the selected machine plugin list", async () => { - stubWindowTimers(); - const baseConfig = configResponse({ - plugins: { - keep: { enabled: true, settings: { level: 1 } }, - info: { settings: { color: "blue" } }, - }, - }); - const savedConfig = configResponse({ - plugins: { - keep: { enabled: true, settings: { level: 1 } }, - info: { enabled: false, settings: { color: "blue" } }, - }, - }); - const refreshedPlugins = pluginsResponse([pluginInfo("info", false), pluginInfo("keep", true)]); - const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig); - const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - setDialogProperty(dialog, "selectedPluginConfigResponse", baseConfig); - - await callDialogPromise(dialog, "togglePlugin", "info", false); - - expect(saveSpy.mock.calls).toEqual([[ - { - plugins: { - keep: { enabled: true, settings: { level: 1 } }, - info: { enabled: false, settings: { color: "blue" } }, - }, - }, - "remote-a", - ]]); - expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]); - expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig); - expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins); - expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved."); - expect(getDialogProperty(dialog, "saving")).toBe(false); - }); - - it("merges local selected-machine plugin saves into gateway config without dropping gateway-only values", async () => { - stubWindowTimers(); - const gatewayConfig = configResponse({ - host: "127.0.0.1", - shortcuts: { "core:view.chat": "mod+1" }, - spawnSessions: false, - plugins: { info: { enabled: false }, gateway: { settings: { theme: "dark" } } }, - }); - const savedConfig = configResponse({ plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } } }); - const refreshedPlugins = pluginsResponse([pluginInfo("info", true)]); - const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig); - vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins); - const onConfigSaved = vi.fn(); - const dialog = new SettingsDialog(); - dialog.onConfigSaved = onConfigSaved; - setDialogProperty(dialog, "configResponse", gatewayConfig); - setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: false } } })); - - await callDialogPromise(dialog, "togglePlugin", "info", true); - - expect(saveSpy.mock.calls).toEqual([[{ plugins: { info: { enabled: true } } }, "local"]]); - expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig); - expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins); - expect(getDialogProperty(dialog, "configResponse")).toMatchObject({ - config: { - host: "127.0.0.1", - shortcuts: { "core:view.chat": "mod+1" }, - spawnSessions: false, - plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } }, - }, - effectiveConfig: { - host: "127.0.0.1", - shortcuts: { "core:view.chat": "mod+1" }, - spawnSessions: false, - plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } }, - }, - }); - expect(onConfigSaved).toHaveBeenCalledWith({ - host: "127.0.0.1", - shortcuts: { "core:view.chat": "mod+1" }, - spawnSessions: false, - plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } }, - }); - }); - - it("ignores stale plugin load responses after the selected machine changes", async () => { - const configLoad = deferred(); - const pluginsLoad = deferred(); - vi.spyOn(configApi, "config").mockReturnValue(configLoad.promise); - vi.spyOn(pluginsApi, "plugins").mockReturnValue(pluginsLoad.promise); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - - const loadPromise = callDialogPromise(dialog, "loadPluginsForTarget"); - expect(getDialogProperty(dialog, "pluginLoading")).toBe(true); - - dialog.machine = secondRemoteMachine; - callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); - configLoad.resolve(configResponse({ plugins: { info: { enabled: true } } })); - pluginsLoad.resolve(pluginsResponse([pluginInfo("info", true)])); - await loadPromise; - - expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined(); - expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined(); - expect(getDialogProperty(dialog, "pluginError")).toBe(""); - expect(getDialogProperty(dialog, "pluginLoading")).toBe(false); - }); - - it("ignores stale plugin save responses after the selected machine changes", async () => { - const save = deferred(); - const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", false)])); - vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } })); - - const savePromise = callDialogPromise(dialog, "togglePlugin", "info", false); - expect(getDialogProperty(dialog, "saving")).toBe(true); - - dialog.machine = secondRemoteMachine; - callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); - save.resolve(configResponse({ plugins: { info: { enabled: false } } })); - await savePromise; - - expect(pluginsSpy).not.toHaveBeenCalled(); - expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined(); - expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined(); - expect(getDialogProperty(dialog, "savedMessage")).toBe(""); - expect(getDialogProperty(dialog, "saving")).toBe(false); - }); -}); - -const remoteMachine: Machine = { - id: "remote-a", - name: "Lab Mac", - kind: "remote", - baseUrl: "https://lab.example.test", - createdAt: "2026-07-01T00:00:00.000Z", - updatedAt: "2026-07-01T00:00:00.000Z", -}; - -const secondRemoteMachine: Machine = { - id: "remote-b", - name: "Build Box", - kind: "remote", - baseUrl: "https://build.example.test", - createdAt: "2026-07-01T00:00:00.000Z", - updatedAt: "2026-07-01T00:00:00.000Z", -}; - -const runtimeWithPackageManagement: MachineRuntime = { - machineId: "remote-a", - ok: true, - checkedAt: "2026-07-01T00:00:00.000Z", - capabilities: [PI_WEB_CAPABILITIES.piPackagesManage], -}; - -const runtimeWithoutSelectedMachineSettings: MachineRuntime = runtimeWithPackageManagement; - -function getDialogProperty(dialog: SettingsDialog, property: string): unknown { - return Reflect.get(dialog, property); -} - -function setDialogProperty(dialog: SettingsDialog, property: string, value: unknown): void { - if (!Reflect.set(dialog, property, value)) throw new Error(`Failed to set SettingsDialog property ${property}`); -} - -async function callDialogPromise(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): Promise { - const result = callDialogMethod(dialog, methodName, ...args); - if (!(result instanceof Promise)) throw new Error(`SettingsDialog.${methodName} did not return a promise`); - await result; -} - -function callDialogUpdated(dialog: SettingsDialog, changed: Map): void { - const result = callDialogMethod(dialog, "updated", changed); - if (result !== undefined) throw new Error("SettingsDialog.updated returned an unexpected value"); -} - -function callDialogMethod(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): unknown { - const method: unknown = Reflect.get(dialog, methodName); - if (!isDialogMethod(method)) throw new Error(`SettingsDialog.${methodName} is not callable`); - return method.call(dialog, ...args); -} - -function isDialogMethod(value: unknown): value is (this: SettingsDialog, ...args: readonly unknown[]) => unknown { - return typeof value === "function"; -} - -function collectTemplateStrings(template: TemplateResult): string[] { - const strings: string[] = []; - visitTemplate(template); - return strings; - - function visitTemplate(current: TemplateResult): void { - strings.push(...templateStrings(current)); - for (const value of templateValues(current)) { - if (Array.isArray(value)) { - for (const item of value) if (isTemplateResult(item)) visitTemplate(item); - } else if (isTemplateResult(value)) { - visitTemplate(value); - } - } - } -} - -function templateStrings(template: TemplateResult): readonly string[] { - const strings = Reflect.get(template, "strings"); - if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); - return strings; -} - -function templateValues(template: TemplateResult): readonly unknown[] { - const values = Reflect.get(template, "values"); - if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable"); - return values.map((value: unknown) => value); -} - -function isTemplateResult(value: unknown): value is TemplateResult { - return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values")); -} - -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); -} - -function configResponse(config: PiWebConfigValues): PiWebConfigResponse { - return { - path: "/tmp/pi-web/config.json", - exists: true, - config, - effectiveConfig: config, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, - }; -} - -function pluginsResponse(plugins: PiWebPluginInfo[]): PiWebPluginsResponse { - return { plugins }; -} - -function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo { - return { - id, - module: `/pi-web-plugins/${id}/plugin.js`, - source: "test", - scope: "local", - machineSpecific: false, - enabled, - }; -} - -function packageInfo(source: string): PiPackageInfo { - return { source, scope: "user", filtered: false, installedPath: `/pi/packages/${source}` }; -} - -function packageMutationResponse(action: PiPackageMutationResponse["action"], packages: PiPackageInfo[], source?: string): PiPackageMutationResponse { - return source === undefined ? { action, packages } : { action, source, packages }; -} - -interface Deferred { - promise: Promise; - resolve: (value: T) => void; - reject: (error: unknown) => void; -} - -function deferred(): Deferred { - let resolveDeferred: ((value: T) => void) | undefined; - let rejectDeferred: ((error: unknown) => void) | undefined; - const promise = new Promise((resolve, reject) => { - resolveDeferred = resolve; - rejectDeferred = reject; - }); - if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized"); - return { promise, resolve: resolveDeferred, reject: rejectDeferred }; -} - -function stubWindowTimers(): void { - vi.stubGlobal("window", { - clearTimeout: vi.fn(), - setTimeout: vi.fn(() => 1), - }); -} diff --git a/src/client/src/components/SettingsDialog.testSupport.ts b/src/client/src/components/SettingsDialog.testSupport.ts new file mode 100644 index 0000000..c4574f0 --- /dev/null +++ b/src/client/src/components/SettingsDialog.testSupport.ts @@ -0,0 +1,153 @@ +import type { TemplateResult } from "lit"; +import { vi } from "vitest"; +import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; +import type { Machine, MachineRuntime, PiPackageInfo, PiPackageMutationResponse, PiWebConfigResponse, PiWebConfigValues, PiWebPluginInfo, PiWebPluginsResponse } from "../api"; +import { SettingsDialog } from "./SettingsDialog"; + +export const remoteMachine: Machine = { + id: "remote-a", + name: "Lab Mac", + kind: "remote", + baseUrl: "https://lab.example.test", + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", +}; + +export const secondRemoteMachine: Machine = { + id: "remote-b", + name: "Build Box", + kind: "remote", + baseUrl: "https://build.example.test", + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", +}; + +export const runtimeWithPackageManagement: MachineRuntime = { + machineId: "remote-a", + ok: true, + checkedAt: "2026-07-01T00:00:00.000Z", + capabilities: [PI_WEB_CAPABILITIES.piPackagesManage], +}; + +export function getDialogProperty(dialog: SettingsDialog, property: string): unknown { + return Reflect.get(dialog, property); +} + +export function setDialogProperty(dialog: SettingsDialog, property: string, value: unknown): void { + if (!Reflect.set(dialog, property, value)) throw new Error(`Failed to set SettingsDialog property ${property}`); +} + +export async function callDialogPromise(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): Promise { + const result = callDialogMethod(dialog, methodName, ...args); + if (!(result instanceof Promise)) throw new Error(`SettingsDialog.${methodName} did not return a promise`); + await result; +} + +export function callDialogUpdated(dialog: SettingsDialog, changed: Map): void { + const result = callDialogMethod(dialog, "updated", changed); + if (result !== undefined) throw new Error("SettingsDialog.updated returned an unexpected value"); +} + +function callDialogMethod(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): unknown { + const method: unknown = Reflect.get(dialog, methodName); + if (!isDialogMethod(method)) throw new Error(`SettingsDialog.${methodName} is not callable`); + return method.call(dialog, ...args); +} + +function isDialogMethod(value: unknown): value is (this: SettingsDialog, ...args: readonly unknown[]) => unknown { + return typeof value === "function"; +} + +export function collectTemplateStrings(template: TemplateResult): string[] { + const strings: string[] = []; + visitTemplate(template); + return strings; + + function visitTemplate(current: TemplateResult): void { + strings.push(...templateStrings(current)); + for (const value of templateValues(current)) { + if (Array.isArray(value)) { + for (const item of value) if (isTemplateResult(item)) visitTemplate(item); + } else if (isTemplateResult(value)) { + visitTemplate(value); + } + } + } +} + +function templateStrings(template: TemplateResult): readonly string[] { + const strings = Reflect.get(template, "strings"); + if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); + return strings; +} + +function templateValues(template: TemplateResult): readonly unknown[] { + const values = Reflect.get(template, "values"); + if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable"); + return values.map((value: unknown) => value); +} + +function isTemplateResult(value: unknown): value is TemplateResult { + return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values")); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); +} + +export function configResponse(config: PiWebConfigValues): PiWebConfigResponse { + return { + path: "/tmp/pi-web/config.json", + exists: true, + config, + effectiveConfig: config, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + }; +} + +export function pluginsResponse(plugins: PiWebPluginInfo[]): PiWebPluginsResponse { + return { plugins }; +} + +export function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo { + return { + id, + module: `/pi-web-plugins/${id}/plugin.js`, + source: "test", + scope: "local", + machineSpecific: false, + enabled, + }; +} + +export function packageInfo(source: string): PiPackageInfo { + return { source, scope: "user", filtered: false, installedPath: `/pi/packages/${source}` }; +} + +export function packageMutationResponse(action: PiPackageMutationResponse["action"], packages: PiPackageInfo[], source?: string): PiPackageMutationResponse { + return source === undefined ? { action, packages } : { action, source, packages }; +} + +export interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +export function deferred(): Deferred { + let resolveDeferred: ((value: T) => void) | undefined; + let rejectDeferred: ((error: unknown) => void) | undefined; + const promise = new Promise((resolve, reject) => { + resolveDeferred = resolve; + rejectDeferred = reject; + }); + if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized"); + return { promise, resolve: resolveDeferred, reject: rejectDeferred }; +} + +export function stubWindowTimers(): void { + vi.stubGlobal("window", { + clearTimeout: vi.fn(), + setTimeout: vi.fn(() => 1), + }); +} From 4c084b77cc7dc8630f9406cf24b96fa031f6e560 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 4 Jul 2026 23:41:49 +0200 Subject: [PATCH 057/111] test(server): split app route specs --- src/server/app.localAliases.test.ts | 82 ++ src/server/app.machines.test.ts | 153 ++++ src/server/app.piPackages.test.ts | 25 + src/server/app.plugins.test.ts | 118 +++ src/server/app.projects.test.ts | 112 +++ src/server/app.remoteProxy.test.ts | 184 ++++ src/server/app.test.ts | 1145 ------------------------- src/server/app.testSupport.ts | 246 ++++++ src/server/app.workspaceFiles.test.ts | 332 +++++++ 9 files changed, 1252 insertions(+), 1145 deletions(-) create mode 100644 src/server/app.localAliases.test.ts create mode 100644 src/server/app.machines.test.ts create mode 100644 src/server/app.piPackages.test.ts create mode 100644 src/server/app.plugins.test.ts create mode 100644 src/server/app.projects.test.ts create mode 100644 src/server/app.remoteProxy.test.ts delete mode 100644 src/server/app.test.ts create mode 100644 src/server/app.testSupport.ts create mode 100644 src/server/app.workspaceFiles.test.ts diff --git a/src/server/app.localAliases.test.ts b/src/server/app.localAliases.test.ts new file mode 100644 index 0000000..cf8e273 --- /dev/null +++ b/src/server/app.localAliases.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { Project, Workspace } from "./types.js"; +import { appTestContext, registerAppTestHooks } from "./app.testSupport.js"; + +registerAppTestHooks(); + +describe("buildApp local machine aliases", () => { + it("serves local session and terminal proxy routes through machine-scoped aliases", async () => { + const sessionsResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/local/sessions?cwd=${encodeURIComponent(appTestContext.projectDir)}` }); + + expect(sessionsResponse.statusCode).toBe(200); + expect(sessionsResponse.json()).toEqual({ method: "GET", path: `/sessions?cwd=${encodeURIComponent(appTestContext.projectDir)}` }); + expect(appTestContext.sessionDaemonRequests).toEqual([{ method: "GET", path: `/sessions?cwd=${encodeURIComponent(appTestContext.projectDir)}` }]); + + const addResponse = await appTestContext.app.inject({ + method: "POST", + url: "/api/machines/local/projects", + payload: { name: "Machine Local", path: appTestContext.projectDir, create: true }, + }); + const project = addResponse.json(); + const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` }); + const workspace = workspacesResponse.json()[0]; + if (workspace === undefined) throw new Error("Expected workspace"); + + const terminalResponse = await appTestContext.app.inject({ + method: "POST", + url: `/api/machines/local/projects/${project.id}/workspaces/${workspace.id}/terminal-command-runs`, + payload: { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } }, + }); + + const closeTerminalsResponse = await appTestContext.app.inject({ method: "DELETE", url: `/api/machines/local/projects/${project.id}/workspaces/${workspace.id}/terminals` }); + + expect(terminalResponse.statusCode).toBe(200); + expect(terminalResponse.json()).toEqual({ + method: "POST", + path: "/terminal-command-runs", + body: { + origin: "core", + projectId: project.id, + workspaceId: workspace.id, + cwd: appTestContext.projectDir, + title: "Build", + command: "npm test", + metadata: { "pi.operation": "test" }, + }, + }); + expect(closeTerminalsResponse.statusCode).toBe(200); + expect(closeTerminalsResponse.json()).toEqual({ method: "DELETE", path: `/terminals?cwd=${encodeURIComponent(appTestContext.projectDir)}` }); + expect(appTestContext.sessionDaemonRequests[1]).toEqual({ + method: "POST", + path: "/terminal-command-runs", + body: { + origin: "core", + projectId: project.id, + workspaceId: workspace.id, + cwd: appTestContext.projectDir, + title: "Build", + command: "npm test", + metadata: { "pi.operation": "test" }, + }, + }); + expect(appTestContext.sessionDaemonRequests[2]).toEqual({ method: "DELETE", path: `/terminals?cwd=${encodeURIComponent(appTestContext.projectDir)}` }); + }); + + it("serves local projects and workspaces through machine-scoped aliases", async () => { + const addResponse = await appTestContext.app.inject({ + method: "POST", + url: "/api/machines/local/projects", + payload: { name: "Machine Local", path: appTestContext.projectDir, create: true }, + }); + expect(addResponse.statusCode).toBe(200); + const project = addResponse.json(); + + const listResponse = await appTestContext.app.inject({ method: "GET", url: "/api/machines/local/projects" }); + expect(listResponse.statusCode).toBe(200); + expect(listResponse.json()).toEqual([project]); + + const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` }); + expect(workspacesResponse.statusCode).toBe(200); + expect(workspacesResponse.json()).toEqual([expect.objectContaining({ projectId: project.id, path: appTestContext.projectDir })]); + }); +}); diff --git a/src/server/app.machines.test.ts b/src/server/app.machines.test.ts new file mode 100644 index 0000000..93d95cc --- /dev/null +++ b/src/server/app.machines.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MachineClient } from "./machines/machineClient.js"; +import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js"; +import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; +import { appTestContext, configFromMachineConfigWriteBody, fakeRemoteClient, fullPiWebConfig, piWebConfigResponse, registerAppTestHooks, selectedMachinePiWebConfig } from "./app.testSupport.js"; + +registerAppTestHooks(); + +describe("buildApp machine routes", () => { + it("lists synthesized local machine through the HTTP contract", async () => { + const response = await appTestContext.app.inject({ method: "GET", url: "/api/machines" }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ machines: [{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" }] }); + }); + + it("adds remote machines without exposing tokens", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/", token: "secret" } }); + + expect(addResponse.statusCode).toBe(200); + expect(addResponse.json()).toMatchObject({ name: "Remote", kind: "remote", baseUrl: "https://remote.example.test" }); + expect(addResponse.json()).not.toHaveProperty("token"); + }); + + it("reports machine health for local and remote machines", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const requestJson: MachineClient["requestJson"] = () => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: { + packageName: "@jmfederico/pi-web", + generatedAt: "2026-05-25T00:00:00.000Z", + components: { + web: { component: "web", label: "Remote Web", stale: false, available: true }, + sessiond: { component: "sessiond", label: "Remote Sessiond", stale: false, available: true }, + }, + release: { packageName: "@jmfederico/pi-web", updateAvailable: false }, + commands: { update: "", restart: "", restartSystemd: "", restartDev: "" }, + messages: [], + }, + }); + appTestContext.remoteClient = fakeRemoteClient({ requestJson }); + + const localHealth = await appTestContext.app.inject({ method: "GET", url: "/api/machines/local/health" }); + const remoteHealth = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/health` }); + + expect(localHealth.statusCode).toBe(200); + expect(localHealth.json()).toMatchObject({ machineId: "local", ok: true, status: "online" }); + expect(remoteHealth.statusCode).toBe(200); + expect(remoteHealth.json()).toMatchObject({ machineId: remote.id, ok: true, status: "online" }); + }); + + it("reports effective machine runtime capabilities for remote machines", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const requestJson = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: { + packageName: "@jmfederico/pi-web", + generatedAt: "2026-05-25T00:00:00.000Z", + components: { + web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] }, + sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }, + }, + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"], + }, + })); + appTestContext.remoteClient = fakeRemoteClient({ requestJson }); + + const runtime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` }); + + expect(runtime.statusCode).toBe(200); + expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] }); + expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 }); + }); + + it("filters remote selected-machine config reads to machine-safe keys", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const requestJson = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json", "set-cookie": "secret=1" }, + body: piWebConfigResponse(fullPiWebConfig()), + })); + appTestContext.remoteClient = fakeRemoteClient({ requestJson }); + + const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/config` }); + + expect(response.statusCode).toBe(200); + expect(response.headers["set-cookie"]).toBeUndefined(); + expect(response.json()).toEqual({ + ...piWebConfigResponse(fullPiWebConfig()), + config: selectedMachinePiWebConfig(), + effectiveConfig: selectedMachinePiWebConfig(), + }); + expect(requestJson).toHaveBeenCalledWith("GET", "/api/config"); + }); + + it("merges remote selected-machine config updates into the target machine config", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const requestJson = vi.fn((method, _path, body) => { + if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(fullPiWebConfig()) }); + return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(configFromMachineConfigWriteBody(body)) }); + }); + appTestContext.remoteClient = fakeRemoteClient({ requestJson }); + + const response = await appTestContext.app.inject({ + method: "PUT", + url: `/api/machines/${remote.id}/config`, + payload: { config: { plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/srv/remote"] }, uploads: { defaultFolder: "remote\\uploads" }, maxUploadBytes: 4096, spawnSessions: true } }, + }); + + const expectedMerged: PiWebConfigValues = { + ...fullPiWebConfig(), + plugins: { info: { enabled: false } }, + pathAccess: { allowedPaths: ["/srv/remote"] }, + uploads: { defaultFolder: "remote/uploads" }, + maxUploadBytes: 4096, + spawnSessions: true, + }; + expect(response.statusCode).toBe(200); + expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config"); + expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", { config: expectedMerged }); + expect(response.json().config).toEqual({ + plugins: { info: { enabled: false } }, + pathAccess: { allowedPaths: ["/srv/remote"] }, + uploads: { defaultFolder: "remote/uploads" }, + maxUploadBytes: 4096, + spawnSessions: true, + subsessions: false, + }); + }); + + it("rejects unsafe remote selected-machine config keys before proxying", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const requestJson = vi.fn(); + appTestContext.remoteClient = fakeRemoteClient({ requestJson }); + + const response = await appTestContext.app.inject({ + method: "PUT", + url: `/api/machines/${remote.id}/config`, + payload: { config: { host: "0.0.0.0", allowedHosts: true, shortcuts: { "core:view.chat": "mod+1" }, spawnSessions: true } }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config key is not allowed: host"); + expect(requestJson).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/app.piPackages.test.ts b/src/server/app.piPackages.test.ts new file mode 100644 index 0000000..065af09 --- /dev/null +++ b/src/server/app.piPackages.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { appTestContext, registerAppTestHooks } from "./app.testSupport.js"; + +registerAppTestHooks(); + +describe("buildApp Pi package routes", () => { + it("serves Pi package management routes through the app wiring", async () => { + const listResponse = await appTestContext.app.inject({ method: "GET", url: "/api/pi-packages" }); + expect(listResponse.statusCode).toBe(200); + expect(listResponse.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }] }); + + const installResponse = await appTestContext.app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools" } }); + expect(installResponse.statusCode).toBe(200); + expect(installResponse.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" }); + + const localAliasResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines/local/pi-packages/remove", payload: { source: "npm:@acme/tools", scope: "user" } }); + expect(localAliasResponse.statusCode).toBe(200); + expect(localAliasResponse.json()).toMatchObject({ action: "remove", source: "npm:@acme/tools", scope: "user" }); + expect(appTestContext.piPackageRequests).toEqual([ + { action: "list" }, + { action: "install", source: "npm:@acme/new-tools" }, + { action: "remove", source: "npm:@acme/tools", scope: "user" }, + ]); + }); +}); diff --git a/src/server/app.plugins.test.ts b/src/server/app.plugins.test.ts new file mode 100644 index 0000000..1969335 --- /dev/null +++ b/src/server/app.plugins.test.ts @@ -0,0 +1,118 @@ +import { Readable } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { machineScopedPluginId } from "../shared/machinePluginIds.js"; +import { appTestContext, fakeRemoteClient, registerAppTestHooks } from "./app.testSupport.js"; + +registerAppTestHooks(); + +describe("buildApp PI WEB plugin routes", () => { + it("serves the PI WEB plugin manifest and plugin assets", async () => { + const manifestResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" }); + expect(manifestResponse.statusCode).toBe(200); + expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }); + + const pluginsResponse = await appTestContext.app.inject({ method: "GET", url: "/api/plugins" }); + expect(pluginsResponse.statusCode).toBe(200); + expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }); + + const localMachinePluginsResponse = await appTestContext.app.inject({ method: "GET", url: "/api/machines/local/plugins" }); + expect(localMachinePluginsResponse.statusCode).toBe(200); + expect(localMachinePluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }); + + const assetResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" }); + expect(assetResponse.statusCode).toBe(200); + expect(assetResponse.headers["content-type"]).toContain("application/javascript"); + expect(assetResponse.body).toBe("export default {};"); + + const missingResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/missing.js" }); + expect(missingResponse.statusCode).toBe(404); + }); + + it("proxies remote machine plugin lists for settings", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const request = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json", "set-cookie": "secret=1" }, + body: Readable.from([JSON.stringify({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] })]), + })); + appTestContext.remoteClient = fakeRemoteClient({ request }); + + const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/plugins` }); + + expect(response.statusCode).toBe(200); + expect(response.headers["set-cookie"]).toBeUndefined(); + expect(response.json()).toEqual({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] }); + expect(request).toHaveBeenCalledWith("GET", "/api/plugins", undefined); + }); + + it("rewrites and proxies remote machine plugin manifests and assets", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const requestJson = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local", machineSpecific: true }] }, + })); + const request = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/javascript", "set-cookie": "secret=1" }, + body: Readable.from(["export default {};"]), + })); + appTestContext.remoteClient = fakeRemoteClient({ requestJson, request }); + + const manifestResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` }); + const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools"); + expect(manifestResponse.statusCode).toBe(200); + expect(manifestResponse.json()).toEqual({ + plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local", machineSpecific: true }], + }); + expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 }); + + const assetResponse = await appTestContext.app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123` }); + expect(assetResponse.statusCode).toBe(200); + expect(assetResponse.headers["content-type"]).toContain("application/javascript"); + expect(assetResponse.headers["set-cookie"]).toBeUndefined(); + expect(assetResponse.body).toBe("export default {};"); + expect(request).toHaveBeenCalledWith("GET", "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123"); + }); + + it("drops unsafe remote machine plugin manifest modules", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + appTestContext.remoteClient = fakeRemoteClient({ + requestJson: vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: { + plugins: [ + { id: "safe-tools", module: "nested/pi-web-plugin.js?v=1", source: "local", scope: "local" }, + { id: "traversal-tools", module: "..%2F..%2Fapi%2Fconfig", source: "local", scope: "local" }, + { id: "wrong-root", module: "/pi-web-plugins/other/pi-web-plugin.js", source: "local", scope: "local" }, + ], + }, + })), + }); + + const manifestResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` }); + + expect(manifestResponse.statusCode).toBe(200); + expect(manifestResponse.json()).toEqual({ + plugins: [{ id: "safe-tools", module: `/pi-web-plugins/${machineScopedPluginId(remote.id, "safe-tools")}/nested/pi-web-plugin.js?v=1`, source: "local", scope: "local" }], + }); + }); + + it("rejects remote machine plugin asset traversal before proxying", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const request = vi.fn(() => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) })); + appTestContext.remoteClient = fakeRemoteClient({ request }); + const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools"); + + const response = await appTestContext.app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/..%2F..%2Fapi%2Fconfig` }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "Invalid remote PI WEB plugin asset path" }); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/app.projects.test.ts b/src/server/app.projects.test.ts new file mode 100644 index 0000000..11e2bc9 --- /dev/null +++ b/src/server/app.projects.test.ts @@ -0,0 +1,112 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { Project, Workspace } from "./types.js"; +import { appTestContext, registerAppTestHooks } from "./app.testSupport.js"; + +registerAppTestHooks(); + +describe("buildApp project routes", () => { + it("adds, lists, and closes projects through the HTTP contract", async () => { + const addResponse = await appTestContext.app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "Example", path: appTestContext.projectDir, create: true }, + }); + + expect(addResponse.statusCode).toBe(200); + const project = addResponse.json(); + expect(project).toMatchObject({ name: "Example", path: appTestContext.projectDir }); + expect(project.id).not.toBe(""); + + const listResponse = await appTestContext.app.inject({ method: "GET", url: "/api/projects" }); + expect(listResponse.statusCode).toBe(200); + expect(listResponse.json()).toEqual([project]); + + const closeResponse = await appTestContext.app.inject({ method: "DELETE", url: `/api/projects/${project.id}` }); + expect(closeResponse.statusCode).toBe(200); + expect(closeResponse.json()).toEqual({ closed: true }); + + const emptyListResponse = await appTestContext.app.inject({ method: "GET", url: "/api/projects" }); + expect(emptyListResponse.json()).toEqual([]); + }); + + it("returns stable errors for invalid project requests", async () => { + const addResponse = await appTestContext.app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "Missing", path: join(appTestContext.tempDir, "missing") }, + }); + + expect(addResponse.statusCode).toBe(400); + expect(addResponse.json()).toHaveProperty("error"); + + const closeResponse = await appTestContext.app.inject({ method: "DELETE", url: "/api/projects/does-not-exist" }); + expect(closeResponse.statusCode).toBe(404); + expect(closeResponse.json()).toEqual({ error: "Project not found" }); + }); + + it("lists a non-git project as a single workspace", async () => { + const addResponse = await appTestContext.app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "Plain", path: appTestContext.projectDir, create: true }, + }); + const project = addResponse.json(); + + const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + + expect(workspacesResponse.statusCode).toBe(200); + expect(workspacesResponse.json()).toEqual([ + expect.objectContaining({ + projectId: project.id, + path: appTestContext.projectDir, + label: "Plain", + isMain: true, + isGitRepo: false, + isGitWorktree: false, + }), + ]); + }); + + it("exposes the default upload config on workspace responses", async () => { + const addResponse = await appTestContext.app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "Upload Defaults", path: appTestContext.projectDir, create: true }, + }); + const project = addResponse.json(); + + const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + + expect(workspacesResponse.statusCode).toBe(200); + expect(workspacesResponse.json()).toEqual([ + expect.objectContaining({ + projectId: project.id, + effectiveConfig: { uploads: { defaultFolder: ".pi-web/uploads" } }, + }), + ]); + }); + + it("lets project-local upload config override global upload config on workspace responses", async () => { + appTestContext.piWebConfig = { uploads: { defaultFolder: "global-uploads" } }; + const addResponse = await appTestContext.app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "Project Upload Defaults", path: appTestContext.projectDir, create: true }, + }); + const project = addResponse.json(); + await mkdir(join(appTestContext.projectDir, ".pi-web"), { recursive: true }); + await writeFile(join(appTestContext.projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, uploads: { defaultFolder: "project-uploads" } }, null, 2)}\n`); + + const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + + expect(workspacesResponse.statusCode).toBe(200); + expect(workspacesResponse.json()).toEqual([ + expect.objectContaining({ + projectId: project.id, + effectiveConfig: { uploads: { defaultFolder: "project-uploads" } }, + }), + ]); + }); +}); diff --git a/src/server/app.remoteProxy.test.ts b/src/server/app.remoteProxy.test.ts new file mode 100644 index 0000000..22ef140 --- /dev/null +++ b/src/server/app.remoteProxy.test.ts @@ -0,0 +1,184 @@ +import { Readable } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { RemoteMachineRequestError, type MachineClient } from "./machines/machineClient.js"; +import { PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS } from "../shared/federatedRoutes.js"; +import { appTestContext, fakeRemoteClient, registerAppTestHooks } from "./app.testSupport.js"; + +registerAppTestHooks(); + +describe("buildApp remote machine proxy routes", () => { + it("proxies allowlisted remote HTTP routes through the selected machine", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const request = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json", connection: "close" }, + body: Readable.from([JSON.stringify([{ id: "p1", name: "Remote Project", path: "/repo", createdAt: "now" }])]), + })); + appTestContext.remoteClient = fakeRemoteClient({ request }); + + const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects?active=true` }); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toContain("application/json"); + expect(response.json()).toEqual([{ id: "p1", name: "Remote Project", path: "/repo", createdAt: "now" }]); + expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined); + }); + + it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const request = vi.fn((method, path, body) => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Readable.from([JSON.stringify({ method, path, body })]), + })); + appTestContext.remoteClient = fakeRemoteClient({ request }); + + const listResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-packages` }); + const installBody = { source: "npm:@acme/new-tools" }; + const installResponse = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/pi-packages/install`, payload: installBody }); + + expect(listResponse.statusCode).toBe(200); + expect(listResponse.json()).toEqual({ method: "GET", path: "/api/pi-packages" }); + expect(installResponse.statusCode).toBe(200); + expect(installResponse.json()).toEqual({ method: "POST", path: "/api/pi-packages/install", body: installBody }); + expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/pi-packages", undefined); + expect(request).toHaveBeenNthCalledWith(2, "POST", "/api/pi-packages/install", installBody, { timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS }); + }); + + it("proxies remote workspace effective upload config through the existing federated workspace route", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const remoteWorkspaces = [{ + id: "w1", + projectId: "p1", + path: "/repo", + label: "main", + isMain: true, + isGitRepo: false, + isGitWorktree: false, + effectiveConfig: { uploads: { defaultFolder: "remote-project-uploads" } }, + }]; + const request = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Readable.from([JSON.stringify(remoteWorkspaces)]), + })); + appTestContext.remoteClient = fakeRemoteClient({ request }); + + const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces` }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual(remoteWorkspaces); + expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces", undefined); + }); + + it("preserves remote file preview security headers while proxying safe response metadata", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const request = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { + "content-type": "image/svg+xml", + "content-security-policy": "sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'", + "x-content-type-options": "nosniff", + "set-cookie": "session=secret", + }, + body: Readable.from([""]), + })); + appTestContext.remoteClient = fakeRemoteClient({ request }); + + const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file/preview?path=${encodeURIComponent("diagram.svg")}` }); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toContain("image/svg+xml"); + expect(response.headers["content-security-policy"]).toContain("sandbox"); + expect(response.headers["x-content-type-options"]).toBe("nosniff"); + expect(response.headers["set-cookie"]).toBeUndefined(); + expect(response.body).toBe(""); + expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces/w1/file/preview?path=diagram.svg", undefined); + }); + + it("proxies remote workspace file writes as raw request bodies", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const request = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Readable.from([JSON.stringify({ path: "image.png", size: payload.length, modifiedAt: "now", created: true })]), + })); + appTestContext.remoteClient = fakeRemoteClient({ request }); + + const response = await appTestContext.app.inject({ + method: "PUT", + url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file?path=${encodeURIComponent("image.png")}`, + payload, + headers: { "content-type": "application/octet-stream" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ path: "image.png", size: payload.length, modifiedAt: "now", created: true }); + expect(request).toHaveBeenCalledWith("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "application/octet-stream" }); + }); + + it("proxies remote terminal command-run and continue routes", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const request = vi.fn((method: string, path: string) => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Readable.from([JSON.stringify({ method, path })]), + })); + appTestContext.remoteClient = fakeRemoteClient({ request }); + + const createBody = { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } }; + const deleteWorkspaceResponse = await appTestContext.app.inject({ method: "DELETE", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1` }); + const createResponse = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminal-command-runs`, payload: createBody }); + const listResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs?projectId=p1&statuses=running` }); + const getResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs/run1` }); + const cancelResponse = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/terminal-command-runs/run1/cancel` }); + const closeWorkspaceTerminalsResponse = await appTestContext.app.inject({ method: "DELETE", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminals` }); + const continueResponse = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminals/t1/continue` }); + + expect(deleteWorkspaceResponse.json()).toEqual({ method: "DELETE", path: "/api/projects/p1/workspaces/w1" }); + expect(createResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminal-command-runs" }); + expect(listResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs?projectId=p1&statuses=running" }); + expect(getResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs/run1" }); + expect(cancelResponse.json()).toEqual({ method: "POST", path: "/api/terminal-command-runs/run1/cancel" }); + expect(closeWorkspaceTerminalsResponse.json()).toEqual({ method: "DELETE", path: "/api/projects/p1/workspaces/w1/terminals" }); + expect(continueResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminals/t1/continue" }); + expect(request).toHaveBeenCalledWith("POST", "/api/projects/p1/workspaces/w1/terminal-command-runs", createBody); + }); + + it("proxies remote session reloads through the selected machine", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const request = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Readable.from([JSON.stringify({ reloaded: true })]), + })); + appTestContext.remoteClient = fakeRemoteClient({ request }); + + const response = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/reload`, payload: { cwd: "/repo" } }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ reloaded: true }); + expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/reload", { cwd: "/repo" }); + }); + + it("forwards remote JSON request bodies and normalizes remote timeouts", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const request = vi.fn(() => Promise.reject(new RemoteMachineRequestError("timed out", 504))); + appTestContext.remoteClient = fakeRemoteClient({ request }); + + const response = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/prompt`, payload: { text: "hello" } }); + + expect(response.statusCode).toBe(504); + expect(response.json()).toMatchObject({ error: "Remote machine timeout", machineId: remote.id, statusCode: 504 }); + expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/prompt", { text: "hello" }); + }); +}); diff --git a/src/server/app.test.ts b/src/server/app.test.ts deleted file mode 100644 index 34b0378..0000000 --- a/src/server/app.test.ts +++ /dev/null @@ -1,1145 +0,0 @@ -import { mkdir, mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { Readable } from "node:stream"; -import type { FastifyInstance } from "fastify"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { buildApp } from "./app.js"; -import { ProjectService } from "./projects/projectService.js"; -import { ProjectStore } from "./storage/projectStore.js"; -import { RemoteMachineRequestError, type MachineClient } from "./machines/machineClient.js"; -import { MachineService } from "./machines/machineService.js"; -import { MachineStore } from "./machines/machineStore.js"; -import { WorkspaceService } from "./workspaces/workspaceService.js"; -import type { PiPackageService } from "./piPackageService.js"; -import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js"; -import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js"; -import { PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS } from "../shared/federatedRoutes.js"; -import { machineScopedPluginId } from "../shared/machinePluginIds.js"; -import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js"; -import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; -import type { Project, Workspace } from "./types.js"; - -let app: FastifyInstance; -let tempDir: string; -let projectDir: string; -let remoteClient: MachineClient | undefined; -let sessionDaemonRequests: CapturedSessionDaemonRequest[]; -let piPackageRequests: CapturedPiPackageRequest[]; -let piWebConfig: PiWebConfigValues; - -beforeEach(async () => { - tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-"))); - projectDir = join(tempDir, "project"); - remoteClient = undefined; - sessionDaemonRequests = []; - piPackageRequests = []; - piWebConfig = {}; - app = await buildApp({ - projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))), - workspaces: new WorkspaceService(), - machines: new MachineService(new MachineStore(join(tempDir, "machines.json")), { - remoteClientFactory: () => { - if (remoteClient === undefined) throw new Error("No remote machine client configured"); - return remoteClient; - }, - now: () => new Date("2026-05-25T00:00:00.000Z"), - localRuntime: () => Promise.resolve({ - packageName: "@jmfederico/pi-web", - generatedAt: "2026-05-25T00:00:00.000Z", - components: { - web: { component: "web", label: "PI WEB", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }, - sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }, - }, - capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived], - }), - }), - sessionDaemon: fakeSessionDaemon(), - config: fakeConfigService(), - piPackages: fakePiPackageService(), - piWebPlugins: { - manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }), - plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }), - readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined), - }, - clientDist: false, - logger: false, - }); -}); - -afterEach(async () => { - await app.close(); - await rm(tempDir, { recursive: true, force: true }); -}); - -describe("buildApp", () => { - it("lists synthesized local machine through the HTTP contract", async () => { - const response = await app.inject({ method: "GET", url: "/api/machines" }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ machines: [{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" }] }); - }); - - it("adds remote machines without exposing tokens", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/", token: "secret" } }); - - expect(addResponse.statusCode).toBe(200); - expect(addResponse.json()).toMatchObject({ name: "Remote", kind: "remote", baseUrl: "https://remote.example.test" }); - expect(addResponse.json()).not.toHaveProperty("token"); - }); - - it("reports machine health for local and remote machines", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const requestJson: MachineClient["requestJson"] = () => Promise.resolve({ - statusCode: 200, - headers: { "content-type": "application/json" }, - body: { - packageName: "@jmfederico/pi-web", - generatedAt: "2026-05-25T00:00:00.000Z", - components: { - web: { component: "web", label: "Remote Web", stale: false, available: true }, - sessiond: { component: "sessiond", label: "Remote Sessiond", stale: false, available: true }, - }, - release: { packageName: "@jmfederico/pi-web", updateAvailable: false }, - commands: { update: "", restart: "", restartSystemd: "", restartDev: "" }, - messages: [], - }, - }); - remoteClient = fakeRemoteClient({ requestJson }); - - const localHealth = await app.inject({ method: "GET", url: "/api/machines/local/health" }); - const remoteHealth = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/health` }); - - expect(localHealth.statusCode).toBe(200); - expect(localHealth.json()).toMatchObject({ machineId: "local", ok: true, status: "online" }); - expect(remoteHealth.statusCode).toBe(200); - expect(remoteHealth.json()).toMatchObject({ machineId: remote.id, ok: true, status: "online" }); - }); - - it("reports effective machine runtime capabilities for remote machines", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const requestJson = vi.fn(() => Promise.resolve({ - statusCode: 200, - headers: { "content-type": "application/json" }, - body: { - packageName: "@jmfederico/pi-web", - generatedAt: "2026-05-25T00:00:00.000Z", - components: { - web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] }, - sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }, - }, - capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"], - }, - })); - remoteClient = fakeRemoteClient({ requestJson }); - - const runtime = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` }); - - expect(runtime.statusCode).toBe(200); - expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] }); - expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 }); - }); - - it("proxies allowlisted remote HTTP routes through the selected machine", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const request = vi.fn(() => Promise.resolve({ - statusCode: 200, - headers: { "content-type": "application/json", connection: "close" }, - body: Readable.from([JSON.stringify([{ id: "p1", name: "Remote Project", path: "/repo", createdAt: "now" }])]), - })); - remoteClient = fakeRemoteClient({ request }); - - const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects?active=true` }); - - expect(response.statusCode).toBe(200); - expect(response.headers["content-type"]).toContain("application/json"); - expect(response.json()).toEqual([{ id: "p1", name: "Remote Project", path: "/repo", createdAt: "now" }]); - expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined); - }); - - it("filters remote selected-machine config reads to machine-safe keys", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const requestJson = vi.fn(() => Promise.resolve({ - statusCode: 200, - headers: { "content-type": "application/json", "set-cookie": "secret=1" }, - body: piWebConfigResponse(fullPiWebConfig()), - })); - remoteClient = fakeRemoteClient({ requestJson }); - - const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/config` }); - - expect(response.statusCode).toBe(200); - expect(response.headers["set-cookie"]).toBeUndefined(); - expect(response.json()).toEqual({ - ...piWebConfigResponse(fullPiWebConfig()), - config: selectedMachinePiWebConfig(), - effectiveConfig: selectedMachinePiWebConfig(), - }); - expect(requestJson).toHaveBeenCalledWith("GET", "/api/config"); - }); - - it("merges remote selected-machine config updates into the target machine config", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const requestJson = vi.fn((method, _path, body) => { - if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(fullPiWebConfig()) }); - return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(configFromMachineConfigWriteBody(body)) }); - }); - remoteClient = fakeRemoteClient({ requestJson }); - - const response = await app.inject({ - method: "PUT", - url: `/api/machines/${remote.id}/config`, - payload: { config: { plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/srv/remote"] }, uploads: { defaultFolder: "remote\\uploads" }, maxUploadBytes: 4096, spawnSessions: true } }, - }); - - const expectedMerged: PiWebConfigValues = { - ...fullPiWebConfig(), - plugins: { info: { enabled: false } }, - pathAccess: { allowedPaths: ["/srv/remote"] }, - uploads: { defaultFolder: "remote/uploads" }, - maxUploadBytes: 4096, - spawnSessions: true, - }; - expect(response.statusCode).toBe(200); - expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config"); - expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", { config: expectedMerged }); - expect(response.json().config).toEqual({ - plugins: { info: { enabled: false } }, - pathAccess: { allowedPaths: ["/srv/remote"] }, - uploads: { defaultFolder: "remote/uploads" }, - maxUploadBytes: 4096, - spawnSessions: true, - subsessions: false, - }); - }); - - it("rejects unsafe remote selected-machine config keys before proxying", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const requestJson = vi.fn(); - remoteClient = fakeRemoteClient({ requestJson }); - - const response = await app.inject({ - method: "PUT", - url: `/api/machines/${remote.id}/config`, - payload: { config: { host: "0.0.0.0", allowedHosts: true, shortcuts: { "core:view.chat": "mod+1" }, spawnSessions: true } }, - }); - - expect(response.statusCode).toBe(400); - expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config key is not allowed: host"); - expect(requestJson).not.toHaveBeenCalled(); - }); - - it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const request = vi.fn((method, path, body) => Promise.resolve({ - statusCode: 200, - headers: { "content-type": "application/json" }, - body: Readable.from([JSON.stringify({ method, path, body })]), - })); - remoteClient = fakeRemoteClient({ request }); - - const listResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-packages` }); - const installBody = { source: "npm:@acme/new-tools" }; - const installResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/pi-packages/install`, payload: installBody }); - - expect(listResponse.statusCode).toBe(200); - expect(listResponse.json()).toEqual({ method: "GET", path: "/api/pi-packages" }); - expect(installResponse.statusCode).toBe(200); - expect(installResponse.json()).toEqual({ method: "POST", path: "/api/pi-packages/install", body: installBody }); - expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/pi-packages", undefined); - expect(request).toHaveBeenNthCalledWith(2, "POST", "/api/pi-packages/install", installBody, { timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS }); - }); - - it("proxies remote workspace effective upload config through the existing federated workspace route", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const remoteWorkspaces = [{ - id: "w1", - projectId: "p1", - path: "/repo", - label: "main", - isMain: true, - isGitRepo: false, - isGitWorktree: false, - effectiveConfig: { uploads: { defaultFolder: "remote-project-uploads" } }, - }]; - const request = vi.fn(() => Promise.resolve({ - statusCode: 200, - headers: { "content-type": "application/json" }, - body: Readable.from([JSON.stringify(remoteWorkspaces)]), - })); - remoteClient = fakeRemoteClient({ request }); - - const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces` }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual(remoteWorkspaces); - expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces", undefined); - }); - - it("preserves remote file preview security headers while proxying safe response metadata", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const request = vi.fn(() => Promise.resolve({ - statusCode: 200, - headers: { - "content-type": "image/svg+xml", - "content-security-policy": "sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'", - "x-content-type-options": "nosniff", - "set-cookie": "session=secret", - }, - body: Readable.from([""]), - })); - remoteClient = fakeRemoteClient({ request }); - - const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file/preview?path=${encodeURIComponent("diagram.svg")}` }); - - expect(response.statusCode).toBe(200); - expect(response.headers["content-type"]).toContain("image/svg+xml"); - expect(response.headers["content-security-policy"]).toContain("sandbox"); - expect(response.headers["x-content-type-options"]).toBe("nosniff"); - expect(response.headers["set-cookie"]).toBeUndefined(); - expect(response.body).toBe(""); - expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces/w1/file/preview?path=diagram.svg", undefined); - }); - - it("proxies remote workspace file writes as raw request bodies", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]); - const request = vi.fn(() => Promise.resolve({ - statusCode: 200, - headers: { "content-type": "application/json" }, - body: Readable.from([JSON.stringify({ path: "image.png", size: payload.length, modifiedAt: "now", created: true })]), - })); - remoteClient = fakeRemoteClient({ request }); - - const response = await app.inject({ - method: "PUT", - url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file?path=${encodeURIComponent("image.png")}`, - payload, - headers: { "content-type": "application/octet-stream" }, - }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ path: "image.png", size: payload.length, modifiedAt: "now", created: true }); - expect(request).toHaveBeenCalledWith("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "application/octet-stream" }); - }); - - it("proxies remote terminal command-run and continue routes", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const request = vi.fn((method: string, path: string) => Promise.resolve({ - statusCode: 200, - headers: { "content-type": "application/json" }, - body: Readable.from([JSON.stringify({ method, path })]), - })); - remoteClient = fakeRemoteClient({ request }); - - const createBody = { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } }; - const deleteWorkspaceResponse = await app.inject({ method: "DELETE", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1` }); - const createResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminal-command-runs`, payload: createBody }); - const listResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs?projectId=p1&statuses=running` }); - const getResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs/run1` }); - const cancelResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/terminal-command-runs/run1/cancel` }); - const closeWorkspaceTerminalsResponse = await app.inject({ method: "DELETE", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminals` }); - const continueResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminals/t1/continue` }); - - expect(deleteWorkspaceResponse.json()).toEqual({ method: "DELETE", path: "/api/projects/p1/workspaces/w1" }); - expect(createResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminal-command-runs" }); - expect(listResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs?projectId=p1&statuses=running" }); - expect(getResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs/run1" }); - expect(cancelResponse.json()).toEqual({ method: "POST", path: "/api/terminal-command-runs/run1/cancel" }); - expect(closeWorkspaceTerminalsResponse.json()).toEqual({ method: "DELETE", path: "/api/projects/p1/workspaces/w1/terminals" }); - expect(continueResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminals/t1/continue" }); - expect(request).toHaveBeenCalledWith("POST", "/api/projects/p1/workspaces/w1/terminal-command-runs", createBody); - }); - - it("proxies remote session reloads through the selected machine", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const request = vi.fn(() => Promise.resolve({ - statusCode: 200, - headers: { "content-type": "application/json" }, - body: Readable.from([JSON.stringify({ reloaded: true })]), - })); - remoteClient = fakeRemoteClient({ request }); - - const response = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/reload`, payload: { cwd: "/repo" } }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ reloaded: true }); - expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/reload", { cwd: "/repo" }); - }); - - it("forwards remote JSON request bodies and normalizes remote timeouts", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const request = vi.fn(() => Promise.reject(new RemoteMachineRequestError("timed out", 504))); - remoteClient = fakeRemoteClient({ request }); - - const response = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/prompt`, payload: { text: "hello" } }); - - expect(response.statusCode).toBe(504); - expect(response.json()).toMatchObject({ error: "Remote machine timeout", machineId: remote.id, statusCode: 504 }); - expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/prompt", { text: "hello" }); - }); - - it("adds, lists, and closes projects through the HTTP contract", async () => { - const addResponse = await app.inject({ - method: "POST", - url: "/api/projects", - payload: { name: "Example", path: projectDir, create: true }, - }); - - expect(addResponse.statusCode).toBe(200); - const project = addResponse.json(); - expect(project).toMatchObject({ name: "Example", path: projectDir }); - expect(project.id).not.toBe(""); - - const listResponse = await app.inject({ method: "GET", url: "/api/projects" }); - expect(listResponse.statusCode).toBe(200); - expect(listResponse.json()).toEqual([project]); - - const closeResponse = await app.inject({ method: "DELETE", url: `/api/projects/${project.id}` }); - expect(closeResponse.statusCode).toBe(200); - expect(closeResponse.json()).toEqual({ closed: true }); - - const emptyListResponse = await app.inject({ method: "GET", url: "/api/projects" }); - expect(emptyListResponse.json()).toEqual([]); - }); - - it("serves local session and terminal proxy routes through machine-scoped aliases", async () => { - const sessionsResponse = await app.inject({ method: "GET", url: `/api/machines/local/sessions?cwd=${encodeURIComponent(projectDir)}` }); - - expect(sessionsResponse.statusCode).toBe(200); - expect(sessionsResponse.json()).toEqual({ method: "GET", path: `/sessions?cwd=${encodeURIComponent(projectDir)}` }); - expect(sessionDaemonRequests).toEqual([{ method: "GET", path: `/sessions?cwd=${encodeURIComponent(projectDir)}` }]); - - const addResponse = await app.inject({ - method: "POST", - url: "/api/machines/local/projects", - payload: { name: "Machine Local", path: projectDir, create: true }, - }); - const project = addResponse.json(); - const workspacesResponse = await app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` }); - const workspace = workspacesResponse.json()[0]; - if (workspace === undefined) throw new Error("Expected workspace"); - - const terminalResponse = await app.inject({ - method: "POST", - url: `/api/machines/local/projects/${project.id}/workspaces/${workspace.id}/terminal-command-runs`, - payload: { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } }, - }); - - const closeTerminalsResponse = await app.inject({ method: "DELETE", url: `/api/machines/local/projects/${project.id}/workspaces/${workspace.id}/terminals` }); - - expect(terminalResponse.statusCode).toBe(200); - expect(terminalResponse.json()).toEqual({ - method: "POST", - path: "/terminal-command-runs", - body: { - origin: "core", - projectId: project.id, - workspaceId: workspace.id, - cwd: projectDir, - title: "Build", - command: "npm test", - metadata: { "pi.operation": "test" }, - }, - }); - expect(closeTerminalsResponse.statusCode).toBe(200); - expect(closeTerminalsResponse.json()).toEqual({ method: "DELETE", path: `/terminals?cwd=${encodeURIComponent(projectDir)}` }); - expect(sessionDaemonRequests[1]).toEqual({ - method: "POST", - path: "/terminal-command-runs", - body: { - origin: "core", - projectId: project.id, - workspaceId: workspace.id, - cwd: projectDir, - title: "Build", - command: "npm test", - metadata: { "pi.operation": "test" }, - }, - }); - expect(sessionDaemonRequests[2]).toEqual({ method: "DELETE", path: `/terminals?cwd=${encodeURIComponent(projectDir)}` }); - }); - - it("serves local projects and workspaces through machine-scoped aliases", async () => { - const addResponse = await app.inject({ - method: "POST", - url: "/api/machines/local/projects", - payload: { name: "Machine Local", path: projectDir, create: true }, - }); - expect(addResponse.statusCode).toBe(200); - const project = addResponse.json(); - - const listResponse = await app.inject({ method: "GET", url: "/api/machines/local/projects" }); - expect(listResponse.statusCode).toBe(200); - expect(listResponse.json()).toEqual([project]); - - const workspacesResponse = await app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` }); - expect(workspacesResponse.statusCode).toBe(200); - expect(workspacesResponse.json()).toEqual([expect.objectContaining({ projectId: project.id, path: projectDir })]); - }); - - it("serves Pi package management routes through the app wiring", async () => { - const listResponse = await app.inject({ method: "GET", url: "/api/pi-packages" }); - expect(listResponse.statusCode).toBe(200); - expect(listResponse.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }] }); - - const installResponse = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools" } }); - expect(installResponse.statusCode).toBe(200); - expect(installResponse.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" }); - - const localAliasResponse = await app.inject({ method: "POST", url: "/api/machines/local/pi-packages/remove", payload: { source: "npm:@acme/tools", scope: "user" } }); - expect(localAliasResponse.statusCode).toBe(200); - expect(localAliasResponse.json()).toMatchObject({ action: "remove", source: "npm:@acme/tools", scope: "user" }); - expect(piPackageRequests).toEqual([ - { action: "list" }, - { action: "install", source: "npm:@acme/new-tools" }, - { action: "remove", source: "npm:@acme/tools", scope: "user" }, - ]); - }); - - it("serves the PI WEB plugin manifest and plugin assets", async () => { - const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" }); - expect(manifestResponse.statusCode).toBe(200); - expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }); - - const pluginsResponse = await app.inject({ method: "GET", url: "/api/plugins" }); - expect(pluginsResponse.statusCode).toBe(200); - expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }); - - const localMachinePluginsResponse = await app.inject({ method: "GET", url: "/api/machines/local/plugins" }); - expect(localMachinePluginsResponse.statusCode).toBe(200); - expect(localMachinePluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }); - - const assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" }); - expect(assetResponse.statusCode).toBe(200); - expect(assetResponse.headers["content-type"]).toContain("application/javascript"); - expect(assetResponse.body).toBe("export default {};"); - - const missingResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/missing.js" }); - expect(missingResponse.statusCode).toBe(404); - }); - - it("proxies remote machine plugin lists for settings", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const request = vi.fn(() => Promise.resolve({ - statusCode: 200, - headers: { "content-type": "application/json", "set-cookie": "secret=1" }, - body: Readable.from([JSON.stringify({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] })]), - })); - remoteClient = fakeRemoteClient({ request }); - - const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/plugins` }); - - expect(response.statusCode).toBe(200); - expect(response.headers["set-cookie"]).toBeUndefined(); - expect(response.json()).toEqual({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] }); - expect(request).toHaveBeenCalledWith("GET", "/api/plugins", undefined); - }); - - it("rewrites and proxies remote machine plugin manifests and assets", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const requestJson = vi.fn(() => Promise.resolve({ - statusCode: 200, - headers: { "content-type": "application/json" }, - body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local", machineSpecific: true }] }, - })); - const request = vi.fn(() => Promise.resolve({ - statusCode: 200, - headers: { "content-type": "application/javascript", "set-cookie": "secret=1" }, - body: Readable.from(["export default {};"]), - })); - remoteClient = fakeRemoteClient({ requestJson, request }); - - const manifestResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` }); - const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools"); - expect(manifestResponse.statusCode).toBe(200); - expect(manifestResponse.json()).toEqual({ - plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local", machineSpecific: true }], - }); - expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 }); - - const assetResponse = await app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123` }); - expect(assetResponse.statusCode).toBe(200); - expect(assetResponse.headers["content-type"]).toContain("application/javascript"); - expect(assetResponse.headers["set-cookie"]).toBeUndefined(); - expect(assetResponse.body).toBe("export default {};"); - expect(request).toHaveBeenCalledWith("GET", "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123"); - }); - - it("drops unsafe remote machine plugin manifest modules", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - remoteClient = fakeRemoteClient({ - requestJson: vi.fn(() => Promise.resolve({ - statusCode: 200, - headers: { "content-type": "application/json" }, - body: { - plugins: [ - { id: "safe-tools", module: "nested/pi-web-plugin.js?v=1", source: "local", scope: "local" }, - { id: "traversal-tools", module: "..%2F..%2Fapi%2Fconfig", source: "local", scope: "local" }, - { id: "wrong-root", module: "/pi-web-plugins/other/pi-web-plugin.js", source: "local", scope: "local" }, - ], - }, - })), - }); - - const manifestResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` }); - - expect(manifestResponse.statusCode).toBe(200); - expect(manifestResponse.json()).toEqual({ - plugins: [{ id: "safe-tools", module: `/pi-web-plugins/${machineScopedPluginId(remote.id, "safe-tools")}/nested/pi-web-plugin.js?v=1`, source: "local", scope: "local" }], - }); - }); - - it("rejects remote machine plugin asset traversal before proxying", async () => { - const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); - const remote = addResponse.json<{ id: string }>(); - const request = vi.fn(() => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) })); - remoteClient = fakeRemoteClient({ request }); - const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools"); - - const response = await app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/..%2F..%2Fapi%2Fconfig` }); - - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ error: "Invalid remote PI WEB plugin asset path" }); - expect(request).not.toHaveBeenCalled(); - }); - - it("returns stable errors for invalid project requests", async () => { - const addResponse = await app.inject({ - method: "POST", - url: "/api/projects", - payload: { name: "Missing", path: join(tempDir, "missing") }, - }); - - expect(addResponse.statusCode).toBe(400); - expect(addResponse.json()).toHaveProperty("error"); - - const closeResponse = await app.inject({ method: "DELETE", url: "/api/projects/does-not-exist" }); - expect(closeResponse.statusCode).toBe(404); - expect(closeResponse.json()).toEqual({ error: "Project not found" }); - }); - - it("lists a non-git project as a single workspace", async () => { - const addResponse = await app.inject({ - method: "POST", - url: "/api/projects", - payload: { name: "Plain", path: projectDir, create: true }, - }); - const project = addResponse.json(); - - const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); - - expect(workspacesResponse.statusCode).toBe(200); - expect(workspacesResponse.json()).toEqual([ - expect.objectContaining({ - projectId: project.id, - path: projectDir, - label: "Plain", - isMain: true, - isGitRepo: false, - isGitWorktree: false, - }), - ]); - }); - - it("exposes the default upload config on workspace responses", async () => { - const addResponse = await app.inject({ - method: "POST", - url: "/api/projects", - payload: { name: "Upload Defaults", path: projectDir, create: true }, - }); - const project = addResponse.json(); - - const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); - - expect(workspacesResponse.statusCode).toBe(200); - expect(workspacesResponse.json()).toEqual([ - expect.objectContaining({ - projectId: project.id, - effectiveConfig: { uploads: { defaultFolder: ".pi-web/uploads" } }, - }), - ]); - }); - - it("lets project-local upload config override global upload config on workspace responses", async () => { - piWebConfig = { uploads: { defaultFolder: "global-uploads" } }; - const addResponse = await app.inject({ - method: "POST", - url: "/api/projects", - payload: { name: "Project Upload Defaults", path: projectDir, create: true }, - }); - const project = addResponse.json(); - await mkdir(join(projectDir, ".pi-web"), { recursive: true }); - await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, uploads: { defaultFolder: "project-uploads" } }, null, 2)}\n`); - - const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); - - expect(workspacesResponse.statusCode).toBe(200); - expect(workspacesResponse.json()).toEqual([ - expect.objectContaining({ - projectId: project.id, - effectiveConfig: { uploads: { defaultFolder: "project-uploads" } }, - }), - ]); - }); - - it("serves supported workspace images as previews", async () => { - const addResponse = await app.inject({ - method: "POST", - url: "/api/projects", - payload: { name: "Images", path: projectDir, create: true }, - }); - const project = addResponse.json(); - const svg = ""; - await writeFile(join(projectDir, "diagram.svg"), svg); - await writeFile(join(projectDir, "note.txt"), "hello"); - await writeFile(join(projectDir, "huge.png"), ""); - await truncate(join(projectDir, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1); - - const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); - const workspace = workspacesResponse.json()[0]; - if (workspace === undefined) throw new Error("Expected workspace"); - - const previewResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("diagram.svg")}` }); - - expect(previewResponse.statusCode).toBe(200); - expect(previewResponse.headers["content-type"]).toContain("image/svg+xml"); - expect(previewResponse.headers["cache-control"]).toBe("private, max-age=3600"); - expect(previewResponse.headers["content-security-policy"]).toContain("sandbox"); - expect(previewResponse.headers["x-content-type-options"]).toBe("nosniff"); - expect(previewResponse.body).toBe(svg); - - const rejectedResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("note.txt")}` }); - expect(rejectedResponse.statusCode).toBe(400); - expect(rejectedResponse.json()).toEqual({ error: "Image preview is not supported for this file type" }); - - const tooLargeResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("huge.png")}` }); - expect(tooLargeResponse.statusCode).toBe(400); - expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" }); - }); - - it("keeps normal file suggestions workspace-local when path access config is invalid", async () => { - const addResponse = await app.inject({ - method: "POST", - url: "/api/projects", - payload: { name: "Local Suggestions", path: projectDir, create: true }, - }); - expect(addResponse.statusCode).toBe(200); - await writeFile(join(projectDir, "sdk.md"), "local sdk\n"); - await mkdir(join(projectDir, ".pi-web"), { recursive: true }); - await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [""] } }, null, 2)}\n`); - - const response = await app.inject({ method: "GET", url: `/api/files?cwd=${encodeURIComponent(projectDir)}&q=sdk&scope=all` }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual([{ path: "sdk.md", kind: "other" }]); - }); - - it("serves project-configured allowed external files through the workspace explorer", async () => { - const addResponse = await app.inject({ - method: "POST", - url: "/api/projects", - payload: { name: "External", path: projectDir, create: true }, - }); - const project = addResponse.json(); - const externalDir = join(tempDir, "external-docs"); - const deniedFile = join(tempDir, "secret.md"); - await mkdir(externalDir); - await writeFile(join(externalDir, "sdk.md"), "external sdk\n"); - await writeFile(deniedFile, "secret\n"); - await mkdir(join(projectDir, ".pi-web"), { recursive: true }); - await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [externalDir] } }, null, 2)}\n`); - - const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); - const workspace = workspacesResponse.json()[0]; - if (workspace === undefined) throw new Error("Expected workspace"); - - const fileResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(join(externalDir, "sdk.md"))}` }); - const treeResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/tree?path=${encodeURIComponent(externalDir)}` }); - const suggestionResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=${encodeURIComponent(join(externalDir, "s"))}` }); - const localSuggestionResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=sdk` }); - const deniedResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(deniedFile)}` }); - - expect(fileResponse.statusCode).toBe(200); - expect(fileResponse.json()).toMatchObject({ path: join(externalDir, "sdk.md"), content: "external sdk\n", binary: false }); - expect(treeResponse.statusCode).toBe(200); - expect(treeResponse.json()).toMatchObject({ - path: externalDir, - entries: [expect.objectContaining({ name: "sdk.md", path: join(externalDir, "sdk.md"), type: "file" })], - truncated: false, - }); - expect(suggestionResponse.statusCode).toBe(200); - expect(suggestionResponse.json()).toEqual([{ path: join(externalDir, "sdk.md"), kind: "other" }]); - expect(localSuggestionResponse.statusCode).toBe(200); - expect(localSuggestionResponse.json()).toEqual([]); - expect(deniedResponse.statusCode).toBe(400); - expect(deniedResponse.json()).toEqual({ error: "Path is outside allowed paths" }); - }); - - it("writes workspace files through the HTTP contract", async () => { - const addResponse = await app.inject({ - method: "POST", - url: "/api/projects", - payload: { name: "WriteTest", path: projectDir, create: true }, - }); - const project = addResponse.json(); - const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); - const workspace = workspacesResponse.json()[0]; - if (workspace === undefined) throw new Error("Expected workspace"); - - const writeTextResponse = await app.inject({ - method: "PUT", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`, - payload: "hello world", - headers: { "content-type": "text/plain" }, - }); - expect(writeTextResponse.statusCode).toBe(200); - expect(writeTextResponse.json()).toMatchObject({ path: "hello.txt", created: true }); - expect(typeof writeTextResponse.json<{ size: unknown }>().size).toBe("number"); - - const readResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}` }); - expect(readResponse.json<{ content: unknown }>().content).toBe("hello world"); - - const writeBinaryResponse = await app.inject({ - method: "PUT", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("image.png")}`, - payload: Buffer.from([0x89, 0x50, 0x4e, 0x47]), - headers: { "content-type": "application/octet-stream" }, - }); - expect(writeBinaryResponse.statusCode).toBe(200); - expect(writeBinaryResponse.json()).toMatchObject({ path: "image.png", created: true }); - - const writeDeepResponse = await app.inject({ - method: "PUT", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}`, - payload: "deep content", - headers: { "content-type": "text/plain" }, - }); - expect(writeDeepResponse.statusCode).toBe(200); - - const readDeepResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}` }); - expect(readDeepResponse.json<{ content: unknown }>().content).toBe("deep content"); - - const overwriteResponse = await app.inject({ - method: "PUT", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`, - payload: "updated", - headers: { "content-type": "text/plain" }, - }); - expect(overwriteResponse.json()).toMatchObject({ path: "hello.txt", created: false }); - - const noOverwriteResponse = await app.inject({ - method: "PUT", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}&overwrite=false`, - payload: "should fail", - headers: { "content-type": "text/plain" }, - }); - expect(noOverwriteResponse.statusCode).toBe(400); - expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists"); - - const traversalResponse = await app.inject({ - method: "PUT", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`, - payload: "evil", - headers: { "content-type": "text/plain" }, - }); - expect(traversalResponse.statusCode).toBe(400); - expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal"); - - const noPathResponse = await app.inject({ - method: "PUT", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`, - payload: "no path", - headers: { "content-type": "text/plain" }, - }); - expect(noPathResponse.statusCode).toBe(400); - expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required"); - - const noDirsResponse = await app.inject({ - method: "PUT", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("nonexistent/parent/file.txt")}&createDirs=false`, - payload: "should fail", - headers: { "content-type": "text/plain" }, - }); - expect(noDirsResponse.statusCode).toBe(400); - - await mkdir(join(projectDir, "subdir"), { recursive: true }); - const dirWriteResponse = await app.inject({ - method: "PUT", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("subdir")}`, - payload: "should fail", - headers: { "content-type": "text/plain" }, - }); - expect(dirWriteResponse.statusCode).toBe(400); - }); - - it("deletes workspace files through the HTTP contract", async () => { - const addResponse = await app.inject({ - method: "POST", - url: "/api/projects", - payload: { name: "DeleteTest", path: projectDir, create: true }, - }); - const project = addResponse.json(); - const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); - const workspace = workspacesResponse.json()[0]; - if (workspace === undefined) throw new Error("Expected workspace"); - - await app.inject({ - method: "PUT", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`, - payload: "delete me", - headers: { "content-type": "text/plain" }, - }); - - const deleteResponse = await app.inject({ - method: "DELETE", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`, - }); - expect(deleteResponse.statusCode).toBe(200); - expect(deleteResponse.json()).toMatchObject({ path: "to-delete.txt", existed: true }); - - const deleteMissingResponse = await app.inject({ - method: "DELETE", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("missing.txt")}`, - }); - expect(deleteMissingResponse.statusCode).toBe(200); - expect(deleteMissingResponse.json()).toMatchObject({ path: "missing.txt", existed: false }); - - const traversalResponse = await app.inject({ - method: "DELETE", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`, - }); - expect(traversalResponse.statusCode).toBe(400); - expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal"); - - const noPathResponse = await app.inject({ - method: "DELETE", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`, - }); - expect(noPathResponse.statusCode).toBe(400); - expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required"); - }); - - it("moves workspace files through the HTTP contract", async () => { - const addResponse = await app.inject({ - method: "POST", - url: "/api/projects", - payload: { name: "MoveTest", path: projectDir, create: true }, - }); - const project = addResponse.json(); - const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); - const workspace = workspacesResponse.json()[0]; - if (workspace === undefined) throw new Error("Expected workspace"); - - await app.inject({ - method: "PUT", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}`, - payload: "move me", - headers: { "content-type": "text/plain" }, - }); - - const moveResponse = await app.inject({ - method: "POST", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("original.txt")}&toPath=${encodeURIComponent("moved.txt")}`, - }); - expect(moveResponse.statusCode).toBe(200); - expect(moveResponse.json()).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" }); - expect(typeof moveResponse.json<{ size: unknown }>().size).toBe("number"); - - const readSourceResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}` }); - expect(readSourceResponse.statusCode).toBe(400); - - const readTargetResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("moved.txt")}` }); - expect(readTargetResponse.statusCode).toBe(200); - expect(readTargetResponse.json<{ content: unknown }>().content).toBe("move me"); - - await app.inject({ - method: "PUT", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source2.txt")}`, - payload: "source", - headers: { "content-type": "text/plain" }, - }); - await app.inject({ - method: "PUT", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target2.txt")}`, - payload: "target", - headers: { "content-type": "text/plain" }, - }); - - const overwriteResponse = await app.inject({ - method: "POST", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source2.txt")}&toPath=${encodeURIComponent("target2.txt")}&overwrite=true`, - }); - expect(overwriteResponse.statusCode).toBe(200); - - await app.inject({ - method: "PUT", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source3.txt")}`, - payload: "s", - headers: { "content-type": "text/plain" }, - }); - await app.inject({ - method: "PUT", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target3.txt")}`, - payload: "t", - headers: { "content-type": "text/plain" }, - }); - const noOverwriteResponse = await app.inject({ - method: "POST", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source3.txt")}&toPath=${encodeURIComponent("target3.txt")}`, - }); - expect(noOverwriteResponse.statusCode).toBe(400); - expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists"); - - const traversalFromResponse = await app.inject({ - method: "POST", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("../../etc/passwd")}&toPath=${encodeURIComponent("safe.txt")}`, - }); - expect(traversalFromResponse.statusCode).toBe(400); - - const noParamsResponse = await app.inject({ - method: "POST", - url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move`, - }); - expect(noParamsResponse.statusCode).toBe(400); - expect(noParamsResponse.json<{ error: string }>().error).toContain("fromPath query parameter is required"); - }); -}); - -interface CapturedSessionDaemonRequest { - method: string; - path: string; - body?: unknown; -} - -interface CapturedPiPackageRequest { - action: "list" | "install" | "remove" | "update"; - source?: string; - scope?: "user" | "project"; -} - -function fakeConfigService() { - return { - read: () => piWebConfigResponse(piWebConfig), - write: (config: PiWebConfigValues) => { - piWebConfig = config; - return piWebConfigResponse(config); - }, - }; -} - -function fullPiWebConfig(): PiWebConfigValues { - return { - host: "127.0.0.1", - port: 8504, - allowedHosts: ["gateway.example.test"], - shortcuts: { "core:view.chat": "mod+1" }, - plugins: { info: { enabled: true, settings: { note: "remote" } } }, - pathAccess: { allowedPaths: ["/srv/repos"] }, - uploads: { defaultFolder: "uploads" }, - maxUploadBytes: 1024, - spawnSessions: false, - subsessions: false, - }; -} - -function selectedMachinePiWebConfig(): PiWebConfigValues { - return { - plugins: { info: { enabled: true, settings: { note: "remote" } } }, - pathAccess: { allowedPaths: ["/srv/repos"] }, - uploads: { defaultFolder: "uploads" }, - maxUploadBytes: 1024, - spawnSessions: false, - subsessions: false, - }; -} - -function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse { - return { - path: join(tempDir, "config.json"), - exists: false, - config, - effectiveConfig: config, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, - }; -} - -interface MachineConfigWriteBody { - config: PiWebConfigValues; -} - -function configFromMachineConfigWriteBody(body: unknown): PiWebConfigValues { - if (!isMachineConfigWriteBody(body)) throw new Error("Expected machine config write body"); - return body.config; -} - -function isMachineConfigWriteBody(value: unknown): value is MachineConfigWriteBody { - if (!isRecord(value)) return false; - return isRecord(value["config"]); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function fakePiPackageService(): PiPackageService { - const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }]; - return { - list: () => { - piPackageRequests.push({ action: "list" }); - return Promise.resolve({ packages }); - }, - install: (source) => { - piPackageRequests.push({ action: "install", source }); - return Promise.resolve({ action: "install", source, packages }); - }, - remove: (source, scope = "user") => { - piPackageRequests.push({ action: "remove", source, scope }); - return Promise.resolve({ action: "remove", source, scope, removed: true, packages }); - }, - update: (source) => { - piPackageRequests.push({ action: "update", ...(source === undefined ? {} : { source }) }); - return Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages }); - }, - }; -} - -function fakeSessionDaemon(): SessionProxyDaemon { - return { - request: (method, path, body) => { - const captured = { method, path, ...(body === undefined ? {} : { body }) } satisfies CapturedSessionDaemonRequest; - sessionDaemonRequests.push(captured); - return Promise.resolve({ - statusCode: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify(captured), - }); - }, - connectWebSocket: () => { throw new Error("WebSocket not configured for test"); }, - }; -} - -function fakeRemoteClient(overrides: Partial): MachineClient { - return { - request: () => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }), - requestJson: () => Promise.resolve({ statusCode: 200, headers: {}, body: undefined }), - connectWebSocket: () => { throw new Error("WebSocket not configured for test"); }, - ...overrides, - }; -} diff --git a/src/server/app.testSupport.ts b/src/server/app.testSupport.ts new file mode 100644 index 0000000..afa20e8 --- /dev/null +++ b/src/server/app.testSupport.ts @@ -0,0 +1,246 @@ +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { Readable } from "node:stream"; +import type { FastifyInstance } from "fastify"; +import { afterEach, beforeEach } from "vitest"; +import { buildApp } from "./app.js"; +import { ProjectService } from "./projects/projectService.js"; +import { ProjectStore } from "./storage/projectStore.js"; +import type { MachineClient } from "./machines/machineClient.js"; +import { MachineService } from "./machines/machineService.js"; +import { MachineStore } from "./machines/machineStore.js"; +import { WorkspaceService } from "./workspaces/workspaceService.js"; +import type { PiPackageService } from "./piPackageService.js"; +import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js"; +import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js"; +import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; + +interface AppTestContext { + readonly app: FastifyInstance; + readonly tempDir: string; + readonly projectDir: string; + remoteClient: MachineClient | undefined; + readonly sessionDaemonRequests: CapturedSessionDaemonRequest[]; + readonly piPackageRequests: CapturedPiPackageRequest[]; + piWebConfig: PiWebConfigValues; +} + +let app: FastifyInstance | undefined; +let tempDir: string | undefined; +let projectDir: string | undefined; +let remoteClient: MachineClient | undefined; +let sessionDaemonRequests: CapturedSessionDaemonRequest[] = []; +let piPackageRequests: CapturedPiPackageRequest[] = []; +let piWebConfig: PiWebConfigValues = {}; + +export const appTestContext: AppTestContext = { + get app() { + if (app === undefined) throw new Error("App test harness was not initialized"); + return app; + }, + get tempDir() { + if (tempDir === undefined) throw new Error("App test tempDir was not initialized"); + return tempDir; + }, + get projectDir() { + if (projectDir === undefined) throw new Error("App test projectDir was not initialized"); + return projectDir; + }, + get remoteClient() { + return remoteClient; + }, + set remoteClient(client) { + remoteClient = client; + }, + get sessionDaemonRequests() { + return sessionDaemonRequests; + }, + get piPackageRequests() { + return piPackageRequests; + }, + get piWebConfig() { + return piWebConfig; + }, + set piWebConfig(config) { + piWebConfig = config; + }, +}; + +export function registerAppTestHooks(): void { + beforeEach(async () => { + tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-"))); + projectDir = join(tempDir, "project"); + remoteClient = undefined; + sessionDaemonRequests = []; + piPackageRequests = []; + piWebConfig = {}; + app = await buildApp({ + projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))), + workspaces: new WorkspaceService(), + machines: new MachineService(new MachineStore(join(tempDir, "machines.json")), { + remoteClientFactory: () => { + if (remoteClient === undefined) throw new Error("No remote machine client configured"); + return remoteClient; + }, + now: () => new Date("2026-05-25T00:00:00.000Z"), + localRuntime: () => Promise.resolve({ + packageName: "@jmfederico/pi-web", + generatedAt: "2026-05-25T00:00:00.000Z", + components: { + web: { component: "web", label: "PI WEB", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }, + sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }, + }, + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived], + }), + }), + sessionDaemon: fakeSessionDaemon(), + config: fakeConfigService(), + piPackages: fakePiPackageService(), + piWebPlugins: { + manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }), + plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }), + readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined), + }, + clientDist: false, + logger: false, + }); + }); + + afterEach(async () => { + const appToClose = app; + const tempDirToRemove = tempDir; + app = undefined; + tempDir = undefined; + projectDir = undefined; + remoteClient = undefined; + sessionDaemonRequests = []; + piPackageRequests = []; + piWebConfig = {}; + + if (appToClose !== undefined) await appToClose.close(); + if (tempDirToRemove !== undefined) await rm(tempDirToRemove, { recursive: true, force: true }); + }); +} + +export interface CapturedSessionDaemonRequest { + method: string; + path: string; + body?: unknown; +} + +interface CapturedPiPackageRequest { + action: "list" | "install" | "remove" | "update"; + source?: string; + scope?: "user" | "project"; +} + +function fakeConfigService() { + return { + read: () => piWebConfigResponse(piWebConfig), + write: (config: PiWebConfigValues) => { + piWebConfig = config; + return piWebConfigResponse(config); + }, + }; +} + +export function fullPiWebConfig(): PiWebConfigValues { + return { + host: "127.0.0.1", + port: 8504, + allowedHosts: ["gateway.example.test"], + shortcuts: { "core:view.chat": "mod+1" }, + plugins: { info: { enabled: true, settings: { note: "remote" } } }, + pathAccess: { allowedPaths: ["/srv/repos"] }, + uploads: { defaultFolder: "uploads" }, + maxUploadBytes: 1024, + spawnSessions: false, + subsessions: false, + }; +} + +export function selectedMachinePiWebConfig(): PiWebConfigValues { + return { + plugins: { info: { enabled: true, settings: { note: "remote" } } }, + pathAccess: { allowedPaths: ["/srv/repos"] }, + uploads: { defaultFolder: "uploads" }, + maxUploadBytes: 1024, + spawnSessions: false, + subsessions: false, + }; +} + +export function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse { + return { + path: join(appTestContext.tempDir, "config.json"), + exists: false, + config, + effectiveConfig: config, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + }; +} + +interface MachineConfigWriteBody { + config: PiWebConfigValues; +} + +export function configFromMachineConfigWriteBody(body: unknown): PiWebConfigValues { + if (!isMachineConfigWriteBody(body)) throw new Error("Expected machine config write body"); + return body.config; +} + +function isMachineConfigWriteBody(value: unknown): value is MachineConfigWriteBody { + if (!isRecord(value)) return false; + return isRecord(value["config"]); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function fakePiPackageService(): PiPackageService { + const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }]; + return { + list: () => { + piPackageRequests.push({ action: "list" }); + return Promise.resolve({ packages }); + }, + install: (source) => { + piPackageRequests.push({ action: "install", source }); + return Promise.resolve({ action: "install", source, packages }); + }, + remove: (source, scope = "user") => { + piPackageRequests.push({ action: "remove", source, scope }); + return Promise.resolve({ action: "remove", source, scope, removed: true, packages }); + }, + update: (source) => { + piPackageRequests.push({ action: "update", ...(source === undefined ? {} : { source }) }); + return Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages }); + }, + }; +} + +function fakeSessionDaemon(): SessionProxyDaemon { + return { + request: (method, path, body) => { + const captured = { method, path, ...(body === undefined ? {} : { body }) } satisfies CapturedSessionDaemonRequest; + sessionDaemonRequests.push(captured); + return Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify(captured), + }); + }, + connectWebSocket: () => { throw new Error("WebSocket not configured for test"); }, + }; +} + +export function fakeRemoteClient(overrides: Partial): MachineClient { + return { + request: () => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }), + requestJson: () => Promise.resolve({ statusCode: 200, headers: {}, body: undefined }), + connectWebSocket: () => { throw new Error("WebSocket not configured for test"); }, + ...overrides, + }; +} diff --git a/src/server/app.workspaceFiles.test.ts b/src/server/app.workspaceFiles.test.ts new file mode 100644 index 0000000..6db51ff --- /dev/null +++ b/src/server/app.workspaceFiles.test.ts @@ -0,0 +1,332 @@ +import { mkdir, truncate, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js"; +import type { Project, Workspace } from "./types.js"; +import { appTestContext, registerAppTestHooks } from "./app.testSupport.js"; + +registerAppTestHooks(); + +describe("buildApp workspace file routes", () => { + it("serves supported workspace images as previews", async () => { + const addResponse = await appTestContext.app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "Images", path: appTestContext.projectDir, create: true }, + }); + const project = addResponse.json(); + const svg = ""; + await writeFile(join(appTestContext.projectDir, "diagram.svg"), svg); + await writeFile(join(appTestContext.projectDir, "note.txt"), "hello"); + await writeFile(join(appTestContext.projectDir, "huge.png"), ""); + await truncate(join(appTestContext.projectDir, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1); + + const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + const workspace = workspacesResponse.json()[0]; + if (workspace === undefined) throw new Error("Expected workspace"); + + const previewResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("diagram.svg")}` }); + + expect(previewResponse.statusCode).toBe(200); + expect(previewResponse.headers["content-type"]).toContain("image/svg+xml"); + expect(previewResponse.headers["cache-control"]).toBe("private, max-age=3600"); + expect(previewResponse.headers["content-security-policy"]).toContain("sandbox"); + expect(previewResponse.headers["x-content-type-options"]).toBe("nosniff"); + expect(previewResponse.body).toBe(svg); + + const rejectedResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("note.txt")}` }); + expect(rejectedResponse.statusCode).toBe(400); + expect(rejectedResponse.json()).toEqual({ error: "Image preview is not supported for this file type" }); + + const tooLargeResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("huge.png")}` }); + expect(tooLargeResponse.statusCode).toBe(400); + expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" }); + }); + + it("keeps normal file suggestions workspace-local when path access config is invalid", async () => { + const addResponse = await appTestContext.app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "Local Suggestions", path: appTestContext.projectDir, create: true }, + }); + expect(addResponse.statusCode).toBe(200); + await writeFile(join(appTestContext.projectDir, "sdk.md"), "local sdk\n"); + await mkdir(join(appTestContext.projectDir, ".pi-web"), { recursive: true }); + await writeFile(join(appTestContext.projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [""] } }, null, 2)}\n`); + + const response = await appTestContext.app.inject({ method: "GET", url: `/api/files?cwd=${encodeURIComponent(appTestContext.projectDir)}&q=sdk&scope=all` }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual([{ path: "sdk.md", kind: "other" }]); + }); + + it("serves project-configured allowed external files through the workspace explorer", async () => { + const addResponse = await appTestContext.app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "External", path: appTestContext.projectDir, create: true }, + }); + const project = addResponse.json(); + const externalDir = join(appTestContext.tempDir, "external-docs"); + const deniedFile = join(appTestContext.tempDir, "secret.md"); + await mkdir(externalDir); + await writeFile(join(externalDir, "sdk.md"), "external sdk\n"); + await writeFile(deniedFile, "secret\n"); + await mkdir(join(appTestContext.projectDir, ".pi-web"), { recursive: true }); + await writeFile(join(appTestContext.projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [externalDir] } }, null, 2)}\n`); + + const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + const workspace = workspacesResponse.json()[0]; + if (workspace === undefined) throw new Error("Expected workspace"); + + const fileResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(join(externalDir, "sdk.md"))}` }); + const treeResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/tree?path=${encodeURIComponent(externalDir)}` }); + const suggestionResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=${encodeURIComponent(join(externalDir, "s"))}` }); + const localSuggestionResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=sdk` }); + const deniedResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(deniedFile)}` }); + + expect(fileResponse.statusCode).toBe(200); + expect(fileResponse.json()).toMatchObject({ path: join(externalDir, "sdk.md"), content: "external sdk\n", binary: false }); + expect(treeResponse.statusCode).toBe(200); + expect(treeResponse.json()).toMatchObject({ + path: externalDir, + entries: [expect.objectContaining({ name: "sdk.md", path: join(externalDir, "sdk.md"), type: "file" })], + truncated: false, + }); + expect(suggestionResponse.statusCode).toBe(200); + expect(suggestionResponse.json()).toEqual([{ path: join(externalDir, "sdk.md"), kind: "other" }]); + expect(localSuggestionResponse.statusCode).toBe(200); + expect(localSuggestionResponse.json()).toEqual([]); + expect(deniedResponse.statusCode).toBe(400); + expect(deniedResponse.json()).toEqual({ error: "Path is outside allowed paths" }); + }); + + it("writes workspace files through the HTTP contract", async () => { + const addResponse = await appTestContext.app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "WriteTest", path: appTestContext.projectDir, create: true }, + }); + const project = addResponse.json(); + const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + const workspace = workspacesResponse.json()[0]; + if (workspace === undefined) throw new Error("Expected workspace"); + + const writeTextResponse = await appTestContext.app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`, + payload: "hello world", + headers: { "content-type": "text/plain" }, + }); + expect(writeTextResponse.statusCode).toBe(200); + expect(writeTextResponse.json()).toMatchObject({ path: "hello.txt", created: true }); + expect(typeof writeTextResponse.json<{ size: unknown }>().size).toBe("number"); + + const readResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}` }); + expect(readResponse.json<{ content: unknown }>().content).toBe("hello world"); + + const writeBinaryResponse = await appTestContext.app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("image.png")}`, + payload: Buffer.from([0x89, 0x50, 0x4e, 0x47]), + headers: { "content-type": "application/octet-stream" }, + }); + expect(writeBinaryResponse.statusCode).toBe(200); + expect(writeBinaryResponse.json()).toMatchObject({ path: "image.png", created: true }); + + const writeDeepResponse = await appTestContext.app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}`, + payload: "deep content", + headers: { "content-type": "text/plain" }, + }); + expect(writeDeepResponse.statusCode).toBe(200); + + const readDeepResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}` }); + expect(readDeepResponse.json<{ content: unknown }>().content).toBe("deep content"); + + const overwriteResponse = await appTestContext.app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`, + payload: "updated", + headers: { "content-type": "text/plain" }, + }); + expect(overwriteResponse.json()).toMatchObject({ path: "hello.txt", created: false }); + + const noOverwriteResponse = await appTestContext.app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}&overwrite=false`, + payload: "should fail", + headers: { "content-type": "text/plain" }, + }); + expect(noOverwriteResponse.statusCode).toBe(400); + expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists"); + + const traversalResponse = await appTestContext.app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`, + payload: "evil", + headers: { "content-type": "text/plain" }, + }); + expect(traversalResponse.statusCode).toBe(400); + expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal"); + + const noPathResponse = await appTestContext.app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`, + payload: "no path", + headers: { "content-type": "text/plain" }, + }); + expect(noPathResponse.statusCode).toBe(400); + expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required"); + + const noDirsResponse = await appTestContext.app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("nonexistent/parent/file.txt")}&createDirs=false`, + payload: "should fail", + headers: { "content-type": "text/plain" }, + }); + expect(noDirsResponse.statusCode).toBe(400); + + await mkdir(join(appTestContext.projectDir, "subdir"), { recursive: true }); + const dirWriteResponse = await appTestContext.app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("subdir")}`, + payload: "should fail", + headers: { "content-type": "text/plain" }, + }); + expect(dirWriteResponse.statusCode).toBe(400); + }); + + it("deletes workspace files through the HTTP contract", async () => { + const addResponse = await appTestContext.app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "DeleteTest", path: appTestContext.projectDir, create: true }, + }); + const project = addResponse.json(); + const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + const workspace = workspacesResponse.json()[0]; + if (workspace === undefined) throw new Error("Expected workspace"); + + await appTestContext.app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`, + payload: "delete me", + headers: { "content-type": "text/plain" }, + }); + + const deleteResponse = await appTestContext.app.inject({ + method: "DELETE", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`, + }); + expect(deleteResponse.statusCode).toBe(200); + expect(deleteResponse.json()).toMatchObject({ path: "to-delete.txt", existed: true }); + + const deleteMissingResponse = await appTestContext.app.inject({ + method: "DELETE", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("missing.txt")}`, + }); + expect(deleteMissingResponse.statusCode).toBe(200); + expect(deleteMissingResponse.json()).toMatchObject({ path: "missing.txt", existed: false }); + + const traversalResponse = await appTestContext.app.inject({ + method: "DELETE", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`, + }); + expect(traversalResponse.statusCode).toBe(400); + expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal"); + + const noPathResponse = await appTestContext.app.inject({ + method: "DELETE", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`, + }); + expect(noPathResponse.statusCode).toBe(400); + expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required"); + }); + + it("moves workspace files through the HTTP contract", async () => { + const addResponse = await appTestContext.app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "MoveTest", path: appTestContext.projectDir, create: true }, + }); + const project = addResponse.json(); + const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + const workspace = workspacesResponse.json()[0]; + if (workspace === undefined) throw new Error("Expected workspace"); + + await appTestContext.app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}`, + payload: "move me", + headers: { "content-type": "text/plain" }, + }); + + const moveResponse = await appTestContext.app.inject({ + method: "POST", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("original.txt")}&toPath=${encodeURIComponent("moved.txt")}`, + }); + expect(moveResponse.statusCode).toBe(200); + expect(moveResponse.json()).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" }); + expect(typeof moveResponse.json<{ size: unknown }>().size).toBe("number"); + + const readSourceResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}` }); + expect(readSourceResponse.statusCode).toBe(400); + + const readTargetResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("moved.txt")}` }); + expect(readTargetResponse.statusCode).toBe(200); + expect(readTargetResponse.json<{ content: unknown }>().content).toBe("move me"); + + await appTestContext.app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source2.txt")}`, + payload: "source", + headers: { "content-type": "text/plain" }, + }); + await appTestContext.app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target2.txt")}`, + payload: "target", + headers: { "content-type": "text/plain" }, + }); + + const overwriteResponse = await appTestContext.app.inject({ + method: "POST", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source2.txt")}&toPath=${encodeURIComponent("target2.txt")}&overwrite=true`, + }); + expect(overwriteResponse.statusCode).toBe(200); + + await appTestContext.app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source3.txt")}`, + payload: "s", + headers: { "content-type": "text/plain" }, + }); + await appTestContext.app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target3.txt")}`, + payload: "t", + headers: { "content-type": "text/plain" }, + }); + const noOverwriteResponse = await appTestContext.app.inject({ + method: "POST", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source3.txt")}&toPath=${encodeURIComponent("target3.txt")}`, + }); + expect(noOverwriteResponse.statusCode).toBe(400); + expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists"); + + const traversalFromResponse = await appTestContext.app.inject({ + method: "POST", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("../../etc/passwd")}&toPath=${encodeURIComponent("safe.txt")}`, + }); + expect(traversalFromResponse.statusCode).toBe(400); + + const noParamsResponse = await appTestContext.app.inject({ + method: "POST", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move`, + }); + expect(noParamsResponse.statusCode).toBe(400); + expect(noParamsResponse.json<{ error: string }>().error).toContain("fromPath query parameter is required"); + }); +}); From 04f9d0e764fce30c7c079b4f6d290de3b1ee6ed7 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 4 Jul 2026 23:48:12 +0200 Subject: [PATCH 058/111] test(client): split session controller specs --- .../sessionController.archiveCleanup.test.ts | 378 ++++ .../sessionController.cachedNew.test.ts | 145 ++ .../sessionController.liveEvents.test.ts | 161 ++ .../sessionController.pendingStarts.test.ts | 296 +++ .../sessionController.reloadSelection.test.ts | 148 ++ .../sessionController.sendQueue.test.ts | 354 ++++ .../src/controllers/sessionController.test.ts | 1614 ----------------- .../sessionController.testSupport.ts | 170 ++ 8 files changed, 1652 insertions(+), 1614 deletions(-) create mode 100644 src/client/src/controllers/sessionController.archiveCleanup.test.ts create mode 100644 src/client/src/controllers/sessionController.cachedNew.test.ts create mode 100644 src/client/src/controllers/sessionController.liveEvents.test.ts create mode 100644 src/client/src/controllers/sessionController.pendingStarts.test.ts create mode 100644 src/client/src/controllers/sessionController.reloadSelection.test.ts create mode 100644 src/client/src/controllers/sessionController.sendQueue.test.ts delete mode 100644 src/client/src/controllers/sessionController.test.ts create mode 100644 src/client/src/controllers/sessionController.testSupport.ts diff --git a/src/client/src/controllers/sessionController.archiveCleanup.test.ts b/src/client/src/controllers/sessionController.archiveCleanup.test.ts new file mode 100644 index 0000000..71f27bb --- /dev/null +++ b/src/client/src/controllers/sessionController.archiveCleanup.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it } from "vitest"; +import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; +import { initialAppState } from "../appState"; +import { SessionController } from "./sessionController"; +import { InMemorySessionSelectionMemory } from "./sessionSelection"; +import { defaultApi, emptyPage, FakeSocket, oldSession, sessionLookupId, status, workspace, type AppState } from "./sessionController.testSupport"; + +describe("SessionController archive and cleanup", () => { + it("forgets the selected active session when archiving leaves only archived sessions", async () => { + const persistedSession = { ...oldSession, persisted: true }; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession] }; + const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = []; + const api: typeof defaultApi = { + ...defaultApi, + archive: () => Promise.resolve({ archived: true }), + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + (options) => { urlUpdates.push(options); }, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.selectSession(persistedSession, { updateUrl: false }); + await controller.archiveSession(); + + expect(state.selectedSession).toBeUndefined(); + expect(state.sessions).toHaveLength(1); + expect(state.sessions[0]).toMatchObject({ ...oldSession, archived: true }); + expect(typeof state.sessions[0]?.archivedAt).toBe("string"); + expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBeUndefined(); + expect(urlUpdates).toEqual([undefined]); + }); + + it("archives legacy sessions when persistence support is not advertised", async () => { + const legacySession = { ...oldSession }; + const archivedIds: string[] = []; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: legacySession, sessions: [legacySession] }; + const api: typeof defaultApi = { + ...defaultApi, + archive: (session) => { + archivedIds.push(sessionLookupId(session)); + return Promise.resolve({ archived: true }); + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.archiveSession(legacySession); + + expect(archivedIds).toEqual([legacySession.id]); + expect(state.sessions[0]).toMatchObject({ id: legacySession.id, archived: true }); + }); + + it("archives selected session descendants and selects the next active session", async () => { + const persistedSession = { ...oldSession, persisted: true }; + const childSession = { ...oldSession, id: "child-session", path: "/tmp/child-session.jsonl", parentSessionPath: persistedSession.path, persisted: true }; + const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true }; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession, childSession, nextSession] }; + const api: typeof defaultApi = { + ...defaultApi, + archiveWithDescendants: () => Promise.resolve({ archived: true, sessionIds: [persistedSession.id, childSession.id], archivedCount: 2, skippedAlreadyArchivedCount: 0 }), + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.selectSession(persistedSession, { updateUrl: false }); + await controller.archiveSessionWithDescendants(persistedSession); + + expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true }); + expect(state.sessions.find((session) => session.id === childSession.id)).toMatchObject({ archived: true }); + expect(state.selectedSession?.id).toBe(nextSession.id); + }); + + it("archives selected sessions in bulk", async () => { + const persistedSession = { ...oldSession, persisted: true }; + const secondSession = { ...oldSession, id: "second-session", path: "/tmp/second-session.jsonl", persisted: true }; + const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true }; + const archivedIds: string[] = []; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession, secondSession, nextSession] }; + const api: typeof defaultApi = { + ...defaultApi, + archive: (session) => { + archivedIds.push(sessionLookupId(session)); + return Promise.resolve({ archived: true }); + }, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.selectSession(persistedSession, { updateUrl: false }); + await controller.archiveSessions([persistedSession, secondSession]); + + expect(archivedIds).toEqual([oldSession.id, secondSession.id]); + expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true }); + expect(state.sessions.find((session) => session.id === secondSession.id)).toMatchObject({ archived: true }); + expect(state.selectedSession?.id).toBe(nextSession.id); + }); + + it("uses true bulk archive when the selected runtime supports it and applies partial failures", async () => { + const persistedSession = { ...oldSession, persisted: true }; + const failedSession = { ...oldSession, id: "failed-session", path: "/tmp/failed-session.jsonl", persisted: true }; + const archiveCalls: { ids: string[]; machineId: string }[] = []; + let state: AppState = { + ...initialAppState(), + selectedWorkspace: workspace, + sessions: [persistedSession, failedSession], + machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsBulkMutations] } }, + }; + const api: typeof defaultApi = { + ...defaultApi, + archiveMany: (sessions, machineId) => { + archiveCalls.push({ ids: sessions.map(sessionLookupId), machineId: machineId ?? "local" }); + return Promise.resolve({ archived: true, archivedSessionIds: [persistedSession.id], failures: [{ sessionId: failedSession.id, error: "busy" }], generatedAt: "now" }); + }, + archive: () => { throw new Error("single archive should not be used"); }, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.selectSession(persistedSession, { updateUrl: false }); + await controller.archiveSessions([persistedSession, failedSession]); + + expect(archiveCalls).toEqual([{ ids: [oldSession.id, failedSession.id], machineId: "local" }]); + expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true }); + expect(state.sessions.find((session) => session.id === failedSession.id)?.archived).toBeUndefined(); + expect(state.selectedSession?.id).toBe(failedSession.id); + expect(state.error).toBe("Archive failed for 1 session: failed-session: busy"); + }); + + it("throttles per-session archive fallback when bulk mutations are unsupported", async () => { + const sessions = Array.from({ length: 6 }, (_value, index) => ({ ...oldSession, id: `session-${String(index)}`, path: `/tmp/session-${String(index)}.jsonl`, persisted: true })); + const resolvers: (() => void)[] = []; + const startedIds: string[] = []; + let activeCount = 0; + let maxActiveCount = 0; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions }; + const api: typeof defaultApi = { + ...defaultApi, + archive: (session) => new Promise((resolve) => { + activeCount += 1; + maxActiveCount = Math.max(maxActiveCount, activeCount); + startedIds.push(sessionLookupId(session)); + resolvers.push(() => { + activeCount -= 1; + resolve({ archived: true }); + }); + }), + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + const archive = controller.archiveSessions(sessions); + await Promise.resolve(); + + expect(startedIds).toHaveLength(4); + resolvers.shift()?.(); + await Promise.resolve(); + await Promise.resolve(); + expect(startedIds).toHaveLength(5); + for (const resolve of resolvers.splice(0)) resolve(); + await Promise.resolve(); + await Promise.resolve(); + for (const resolve of resolvers.splice(0)) resolve(); + await archive; + + expect(maxActiveCount).toBe(4); + expect(state.sessions.every((session) => session.archived === true)).toBe(true); + }); + + it("deletes selected archived sessions in bulk and selects the next current session", async () => { + const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; + const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" }; + const deletedIds: string[] = []; + let state: AppState = { + ...initialAppState(), + selectedWorkspace: workspace, + selectedSession: archivedSession, + sessions: [archivedSession, nextSession], + machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] } }, + }; + const api: typeof defaultApi = { + ...defaultApi, + deleteArchived: (session) => { + deletedIds.push(sessionLookupId(session)); + return Promise.resolve({ deleted: true }); + }, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.deleteArchivedSessions([archivedSession]); + + expect(deletedIds).toEqual([archivedSession.id]); + expect(state.sessions.map((session) => session.id)).toEqual([nextSession.id]); + expect(state.selectedSession?.id).toBe(nextSession.id); + }); + + it("uses true bulk delete when supported and keeps partial failures visible", async () => { + const deletedSession = { ...oldSession, archived: true, archivedAt: "later" }; + const failedSession = { ...oldSession, id: "failed-archived", path: "/tmp/failed-archived.jsonl", archived: true, archivedAt: "later" }; + const deleteCalls: { ids: string[]; machineId: string }[] = []; + let state: AppState = { + ...initialAppState(), + selectedWorkspace: workspace, + selectedSession: deletedSession, + sessions: [deletedSession, failedSession], + machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsBulkMutations] } }, + }; + const api: typeof defaultApi = { + ...defaultApi, + deleteArchivedMany: (sessions, machineId) => { + deleteCalls.push({ ids: sessions.map(sessionLookupId), machineId: machineId ?? "local" }); + return Promise.resolve({ deleted: true, deletedSessionIds: [deletedSession.id], failures: [{ sessionId: failedSession.id, error: "busy" }], generatedAt: "now" }); + }, + deleteArchived: () => { throw new Error("single delete should not be used"); }, + messages: () => Promise.resolve(emptyPage), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.deleteArchivedSessions([deletedSession, failedSession]); + + expect(deleteCalls).toEqual([{ ids: [deletedSession.id, failedSession.id], machineId: "local" }]); + expect(state.sessions.map((session) => session.id)).toEqual([failedSession.id]); + expect(state.selectedSession?.id).toBe(failedSession.id); + expect(state.error).toBe("Delete failed for 1 session: failed-archived: busy"); + }); + + it("applies cleanup execution results and refreshes the current workspace sessions", async () => { + const archivedAt = "2026-06-25T12:00:00.000Z"; + const deletedArchived = { ...oldSession, id: "deleted-archived", path: "/tmp/deleted-archived.jsonl", archived: true, archivedAt: "2026-05-01T00:00:00.000Z" }; + const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" }; + const refreshedArchived = { ...oldSession, archived: true, archivedAt }; + const sessionsCalls: { cwd: string; machineId: string }[] = []; + let state: AppState = { + ...initialAppState(), + selectedWorkspace: workspace, + selectedSession: oldSession, + sessions: [oldSession, deletedArchived, nextSession], + sessionStatuses: { [oldSession.id]: status(oldSession.id), [deletedArchived.id]: status(deletedArchived.id), [nextSession.id]: status(nextSession.id) }, + sessionActivities: { [oldSession.id]: { sessionId: oldSession.id, phase: "idle", label: "idle", at: archivedAt } }, + }; + const api: typeof defaultApi = { + ...defaultApi, + sessions: (cwd, machineId) => { + sessionsCalls.push({ cwd, machineId: machineId ?? "local" }); + return Promise.resolve([refreshedArchived, nextSession]); + }, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.applySessionCleanupResult({ + generatedAt: archivedAt, + thresholds: { archiveIdleDays: 30, deleteArchivedDays: 60 }, + projects: [{ cwd: workspace.path, archiveCount: 1, deleteCount: 1 }], + totals: { archiveCount: 1, deleteCount: 1 }, + archivedSessionIds: [oldSession.id], + deletedSessionIds: [deletedArchived.id], + }); + + expect(sessionsCalls).toEqual([{ cwd: workspace.path, machineId: "local" }]); + expect(state.sessions.map((session) => session.id)).toEqual([oldSession.id, nextSession.id]); + expect(state.sessions[0]).toMatchObject({ id: oldSession.id, archived: true, archivedAt }); + expect(state.selectedSession?.id).toBe(nextSession.id); + expect(state.sessionStatuses[oldSession.id]).toBeUndefined(); + expect(state.sessionStatuses[deletedArchived.id]).toBeUndefined(); + expect(state.sessionActivities[oldSession.id]).toBeUndefined(); + }); + + it("does not delete archived sessions when the selected machine runtime reports no support", async () => { + const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; + const deletedIds: string[] = []; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession], machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [] } } }; + const api: typeof defaultApi = { + ...defaultApi, + deleteArchived: (session) => { + deletedIds.push(sessionLookupId(session)); + return Promise.resolve({ deleted: true }); + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.deleteArchivedSessions([archivedSession]); + + expect(deletedIds).toEqual([]); + expect(state.sessions).toEqual([archivedSession]); + expect(state.error).toContain("requires an updated Pi-Web runtime"); + }); + + it("allows legacy archived-session deletion when runtime support is unknown", async () => { + const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; + const deletedIds: string[] = []; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: archivedSession, sessions: [archivedSession] }; + const api: typeof defaultApi = { + ...defaultApi, + deleteArchived: (session) => { + deletedIds.push(sessionLookupId(session)); + return Promise.resolve({ deleted: true }); + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.deleteArchivedSessions([archivedSession]); + + expect(deletedIds).toEqual([archivedSession.id]); + expect(state.sessions).toEqual([]); + expect(state.error).toBe(""); + }); +}); diff --git a/src/client/src/controllers/sessionController.cachedNew.test.ts b/src/client/src/controllers/sessionController.cachedNew.test.ts new file mode 100644 index 0000000..07ed0d7 --- /dev/null +++ b/src/client/src/controllers/sessionController.cachedNew.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { initialAppState } from "../appState"; +import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions"; +import { loadDraft, saveDraft } from "../promptDraftStorage"; +import { SessionController } from "./sessionController"; +import { defaultApi, emptyPage, FakeSocket, MemoryStorage, oldSession, replacementSession, sessionKey, sessionLookupId, status, workspace, type AppState } from "./sessionController.testSupport"; + +describe("SessionController cached-new sessions", () => { + it("keeps live message count updates when a cached new session becomes persisted", async () => { + const cachedSession = markCachedNewSessionInfo(oldSession); + let resolvePrompt: (() => void) | undefined; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: cachedSession, sessions: [cachedSession] }; + const api: typeof defaultApi = { + ...defaultApi, + prompt: () => new Promise<{ accepted: true }>((resolve) => { resolvePrompt = () => { resolve({ accepted: true }); }; }), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const send = controller.send("hello"); + controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 1 } }); + controller.flushPendingUpdates(); + resolvePrompt?.(); + await send; + + expect(state.sessions[0]?.messageCount).toBe(1); + expect(isCachedNewSessionInfo(state.sessions[0])).toBe(false); + expect(state.selectedSession?.messageCount).toBe(1); + }); + + it("deletes transient server-reported new sessions and clears local state", async () => { + const storage = new MemoryStorage(); + Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); + const transientSession = { ...oldSession, persisted: false }; + const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true }; + const stoppedIds: string[] = []; + let state: AppState = { + ...initialAppState(), + selectedWorkspace: workspace, + selectedSession: transientSession, + sessions: [transientSession, nextSession], + sessionStatuses: { [transientSession.id]: { ...status(transientSession.id), persisted: false } }, + sessionActivities: { [transientSession.id]: { sessionId: transientSession.id, phase: "active", label: "Starting", at: "2026-05-20T00:00:00.000Z" } }, + sendingPrompts: { [transientSession.id]: true }, + }; + const api: typeof defaultApi = { + ...defaultApi, + stop: (session) => { stoppedIds.push(sessionLookupId(session)); return Promise.resolve({ stopped: true }); }, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + saveDraft(sessionKey(transientSession.id), "discard me"); + + await controller.deleteCachedNewSession(transientSession); + + expect(stoppedIds).toEqual([transientSession.id]); + expect(state.sessions.map((session) => session.id)).toEqual([nextSession.id]); + expect(state.sessionStatuses[transientSession.id]).toBeUndefined(); + expect(state.sessionActivities[transientSession.id]).toBeUndefined(); + expect(state.sendingPrompts[transientSession.id]).toBeUndefined(); + expect(loadDraft(sessionKey(transientSession.id))).toBe(""); + expect(state.selectedSession?.id).toBe(nextSession.id); + }); + + it("recreates missing browser-cached new sessions and moves their draft", async () => { + const storage = new MemoryStorage(); + Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); + rememberCachedNewSession(oldSession); + saveDraft(sessionKey(oldSession.id), "draft text"); + + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [markCachedNewSessionInfo(oldSession)] }; + const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = []; + const socket = new FakeSocket(); + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => Promise.resolve(replacementSession), + messages: (session) => { + if (sessionLookupId(session) === oldSession.id) return Promise.reject(new Error("Session not found")); + return Promise.resolve(emptyPage); + }, + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + (options) => { urlUpdates.push(options); }, + undefined, + { api, socket }, + ); + + await controller.selectSession(markCachedNewSessionInfo(oldSession), { updateUrl: false }); + + expect(state.selectedSession?.id).toBe(replacementSession.id); + expect(state.sessions.map((session) => session.id)).toEqual([replacementSession.id]); + expect(socket.connectedSessionIds).toEqual([oldSession.id, replacementSession.id]); + expect(loadDraft(sessionKey(oldSession.id))).toBe(""); + expect(loadDraft(sessionKey(replacementSession.id))).toBe("draft text"); + expect(loadCachedNewSessions().map((session) => session.id)).toEqual([replacementSession.id]); + expect(urlUpdates).toEqual([{ replace: true }]); + }); + + it("stores command prompt drafts for replacement sessions before selecting them", async () => { + const storage = new MemoryStorage(); + Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); + + let state: AppState = { + ...initialAppState(), + selectedWorkspace: workspace, + selectedSession: oldSession, + sessions: [oldSession], + commandDialog: { type: "select", requestId: "r1", title: "Fork from message", options: [{ value: "m1", label: "fork me" }] }, + }; + const urlUpdates: unknown[] = []; + const api: typeof defaultApi = { + ...defaultApi, + respondToCommand: () => Promise.resolve({ type: "done", message: "Session forked", session: replacementSession, promptDraft: "fork me" }), + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + (options) => { urlUpdates.push(options); }, + undefined, + { api, socket: new FakeSocket() }, + ); + + await controller.respondToCommand("r1", "m1"); + + expect(state.commandDialog).toBeUndefined(); + expect(loadDraft(sessionKey(replacementSession.id))).toBe("fork me"); + }); +}); diff --git a/src/client/src/controllers/sessionController.liveEvents.test.ts b/src/client/src/controllers/sessionController.liveEvents.test.ts new file mode 100644 index 0000000..501d9fd --- /dev/null +++ b/src/client/src/controllers/sessionController.liveEvents.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { initialAppState } from "../appState"; +import { SessionController } from "./sessionController"; +import { defaultApi, EmitSocket, emptyPage, FakeSocket, oldSession, runPendingAnimationFrames, status, workspace, type AppState, type SessionActivity, type SessionInfo } from "./sessionController.testSupport"; + +describe("SessionController live events", () => { + it("coalesces rapid status updates into a single state write per frame", () => { + const setStateCalls: Partial[] = []; + let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] }; + const controller = new SessionController( + () => state, + (patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; }, + () => undefined, + undefined, + { socket: new FakeSocket() }, + ); + + controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 1 } }); + controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 2 } }); + controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 3 } }); + + // Nothing applies until the frame is flushed; last-write-wins per session. + expect(setStateCalls).toHaveLength(0); + expect(state.sessionStatuses[oldSession.id]).toBeUndefined(); + + runPendingAnimationFrames(); + + expect(setStateCalls).toHaveLength(1); + expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, messageCount: 3 }); + expect(state.status?.messageCount).toBe(3); + }); + + it("applies the latest activity per session on flush", () => { + const setStateCalls: Partial[] = []; + let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] }; + const controller = new SessionController( + () => state, + (patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; }, + () => undefined, + undefined, + { socket: new FakeSocket() }, + ); + + controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "active", label: "running tool", at: "t1" } }); + controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "idle", label: "idle", at: "t2" } }); + + expect(setStateCalls).toHaveLength(0); + + controller.flushPendingUpdates(); + + expect(state.sessionActivities[oldSession.id]).toMatchObject({ phase: "idle", label: "idle" }); + expect(state.activity?.phase).toBe("idle"); + }); + + it("coalesces status updates delivered over the per-session socket until the frame is flushed", async () => { + const socket = new EmitSocket(); + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] }; + const api: typeof defaultApi = { + ...defaultApi, + messages: () => Promise.resolve(emptyPage), + status: () => Promise.resolve(status(oldSession.id)), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket }, + ); + await controller.selectSession(oldSession, { updateUrl: false }); + + socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 7 } }); + socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 8 } }); + + // Buffered, not applied synchronously. + expect(state.sessionStatuses[oldSession.id]?.messageCount).toBeUndefined(); + + controller.flushPendingUpdates(); + + expect(state.sessionStatuses[oldSession.id]?.messageCount).toBe(8); + expect(state.status?.messageCount).toBe(8); + }); + + it("clears stale active activity when an idle status arrives", () => { + const activeActivity: SessionActivity = { sessionId: oldSession.id, phase: "active", label: "running tool", at: "2026-05-15T00:00:00.000Z" }; + let state: AppState = { + ...initialAppState(), + selectedSession: oldSession, + sessions: [oldSession], + activity: activeActivity, + sessionActivities: { [oldSession.id]: activeActivity }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { socket: new FakeSocket() }, + ); + + controller.applyGlobalEvent({ type: "status.update", status: status(oldSession.id) }); + controller.flushPendingUpdates(); + + expect(state.activity).toBeUndefined(); + expect(state.sessionActivities[oldSession.id]).toBeUndefined(); + expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, isStreaming: false }); + }); + + it("updates visible session message counts from live status events", () => { + let state: AppState = { + ...initialAppState(), + selectedSession: oldSession, + sessions: [oldSession], + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { socket: new FakeSocket() }, + ); + + controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 3 } }); + controller.flushPendingUpdates(); + + expect(state.sessions[0]?.messageCount).toBe(3); + expect(state.selectedSession?.messageCount).toBe(3); + }); + + it("adds a newly created session to the list when it belongs to the selected workspace", () => { + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { socket: new FakeSocket() }, + ); + const spawned: SessionInfo = { ...oldSession, id: "spawned-session", path: "/tmp/spawned-session.jsonl" }; + + controller.applyGlobalEvent({ type: "session.created", session: spawned }); + + expect(state.sessions.map((session) => session.id)).toEqual(["spawned-session", "old-session"]); + }); + + it("ignores a created session for a different workspace or a duplicate id", () => { + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { socket: new FakeSocket() }, + ); + + controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession, id: "other", cwd: "/other-repo" } }); + controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession } }); + + expect(state.sessions.map((session) => session.id)).toEqual(["old-session"]); + }); +}); diff --git a/src/client/src/controllers/sessionController.pendingStarts.test.ts b/src/client/src/controllers/sessionController.pendingStarts.test.ts new file mode 100644 index 0000000..5785516 --- /dev/null +++ b/src/client/src/controllers/sessionController.pendingStarts.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from "vitest"; +import { initialAppState } from "../appState"; +import { isCachedNewSessionInfo, loadCachedNewSessions } from "../cachedNewSessions"; +import { loadDraft, saveDraft } from "../promptDraftStorage"; +import { SessionController } from "./sessionController"; +import { defaultApi, deferred, emptyPage, FakeSocket, MemoryStorage, oldSession, sessionKey, sessionLookupId, status, workspace, type AppState, type SessionInfo } from "./sessionController.testSupport"; + +describe("SessionController pending starts", () => { + it("creates and selects a temporary editable session before backend start resolves", async () => { + const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; + const startRequest = deferred(); + const messageCalls: string[] = []; + const statusCalls: string[] = []; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => startRequest.promise, + messages: (session) => { messageCalls.push(sessionLookupId(session)); return Promise.resolve(emptyPage); }, + status: (session) => { statusCalls.push(sessionLookupId(session)); return Promise.resolve(status(sessionLookupId(session))); }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const start = controller.startSession(); + const temporarySession = state.selectedSession; + + expect(temporarySession?.id).toMatch(/^pending-session-/); + expect(temporarySession?.persisted).toBe(false); + expect(state.sessions.map((session) => session.id)).toEqual([temporarySession?.id]); + expect(state.activity).toMatchObject({ sessionId: temporarySession?.id, phase: "active", label: "Creating session" }); + expect(messageCalls).toEqual([]); + expect(statusCalls).toEqual([]); + + startRequest.resolve(started); + await start; + + expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]); + expect(state.selectedSession?.id).toBe("started-session"); + expect(messageCalls).toEqual(["started-session"]); + expect(statusCalls).toEqual(["started-session"]); + }); + + it("does not duplicate a started session when its session.created broadcast races the HTTP response", async () => { + const storage = new MemoryStorage(); + Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); + const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; + const startRequest = deferred(); + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; + const socket = new FakeSocket(); + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => { + // Simulate the broadcast arriving before the HTTP response resolves. + controller.applyGlobalEvent({ type: "session.created", session: started }); + return startRequest.promise; + }, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket }, + ); + + const start = controller.startSession(); + const temporaryId = state.selectedSession?.id; + + expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]); + + startRequest.resolve(started); + await start; + + expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]); + expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true); + }); + + it("releases unrelated created-session broadcasts after pending starts settle", async () => { + const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; + const otherClientSession: SessionInfo = { ...oldSession, id: "other-client-session", path: "/tmp/other-client-session.jsonl" }; + const startRequest = deferred(); + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => startRequest.promise, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const start = controller.startSession(); + const temporaryId = state.selectedSession?.id; + controller.applyGlobalEvent({ type: "session.created", session: started }); + controller.applyGlobalEvent({ type: "session.created", session: otherClientSession }); + + expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]); + + startRequest.resolve(started); + await start; + + const sessionIds = state.sessions.map((session) => session.id); + expect(sessionIds).not.toContain(temporaryId); + expect(sessionIds.filter((id) => id === started.id)).toHaveLength(1); + expect(sessionIds.filter((id) => id === otherClientSession.id)).toHaveLength(1); + }); + + it("preserves temporary start rows across session-list refreshes before backend resolution", async () => { + const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; + const startRequest = deferred(); + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => startRequest.promise, + sessions: () => Promise.resolve([oldSession]), + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const start = controller.startSession(); + const temporaryId = state.selectedSession?.id; + await controller.refreshCurrentWorkspaceSessions(); + + expect(state.sessions.map((session) => session.id)).toEqual([temporaryId, oldSession.id]); + expect(state.selectedSession?.id).toBe(temporaryId); + + startRequest.resolve(started); + await start; + + expect(state.sessions.map((session) => session.id)).toEqual([started.id, oldSession.id]); + expect(state.selectedSession?.id).toBe(started.id); + }); + + it("tracks multiple pending session starts without blocking another start", async () => { + const firstStarted: SessionInfo = { ...oldSession, id: "started-session-1", path: "/tmp/started-session-1.jsonl" }; + const secondStarted: SessionInfo = { ...oldSession, id: "started-session-2", path: "/tmp/started-session-2.jsonl" }; + const startResolvers: ((session: SessionInfo) => void)[] = []; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => new Promise((resolve) => { startResolvers.push(resolve); }), + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const firstStart = controller.startSession(); + const firstTemporaryId = state.selectedSession?.id; + const secondStart = controller.startSession(); + const secondTemporaryId = state.selectedSession?.id; + + expect(startResolvers).toHaveLength(2); + expect(state.startingSessionCount).toBe(0); + expect(state.sessions.map((session) => session.id)).toEqual([secondTemporaryId, firstTemporaryId]); + expect(state.selectedSession?.id).toBe(secondTemporaryId); + expect(state.sessions.every((session) => session.persisted === false)).toBe(true); + + startResolvers[0]?.(firstStarted); + await firstStart; + + expect(state.sessions.map((session) => session.id)).toEqual([secondTemporaryId, "started-session-1"]); + expect(state.selectedSession?.id).toBe(secondTemporaryId); + + startResolvers[1]?.(secondStarted); + await secondStart; + + expect(state.startingSessionCount).toBe(0); + expect(state.sessions.map((session) => session.id)).toEqual(["started-session-2", "started-session-1"]); + expect(state.selectedSession?.id).toBe("started-session-2"); + }); + + it("moves a temporary session draft and cached-new marker to the resolved session", async () => { + const storage = new MemoryStorage(); + Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); + const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; + const startRequest = deferred(); + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => startRequest.promise, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const start = controller.startSession(); + const temporaryId = state.selectedSession?.id; + if (temporaryId === undefined) throw new Error("Expected temporary session id"); + saveDraft(sessionKey(temporaryId), "draft text"); + + startRequest.resolve(started); + await start; + + expect(loadDraft(sessionKey(temporaryId))).toBe(""); + expect(loadDraft(sessionKey(started.id))).toBe("draft text"); + expect(loadCachedNewSessions().map((session) => session.id)).toEqual([started.id]); + expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true); + }); + + it("keeps a failed temporary start selected with a discardable transient row", async () => { + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => Promise.reject(new Error("backend unavailable")), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + await controller.startSession(); + const temporaryId = state.selectedSession?.id; + + expect(temporaryId).toMatch(/^pending-session-/); + expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]); + expect(state.sessions[0]?.persisted).toBe(false); + expect(state.activity).toMatchObject({ sessionId: temporaryId, phase: "error", label: "Session creation failed" }); + expect(state.error).toContain("backend unavailable"); + + await controller.deleteCachedNewSession(state.sessions[0]); + + expect(state.sessions).toEqual([]); + expect(state.selectedSession).toBeUndefined(); + }); + + it("stops the backend session if a discarded pending start resolves later", async () => { + const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; + const startRequest = deferred(); + const stoppedIds: string[] = []; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => startRequest.promise, + stop: (session) => { stoppedIds.push(sessionLookupId(session)); return Promise.resolve({ stopped: true }); }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const start = controller.startSession(); + const temporaryId = state.selectedSession?.id; + if (temporaryId === undefined) throw new Error("Expected temporary session id"); + await controller.send("queued before discard"); + expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([{ kind: "followUp", text: "queued before discard" }]); + + await controller.deleteCachedNewSession(state.selectedSession); + expect(state.sessions).toEqual([]); + expect(state.selectedSession).toBeUndefined(); + expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined(); + + startRequest.resolve(started); + await start; + + expect(stoppedIds).toEqual([started.id]); + expect(state.sessions).toEqual([]); + expect(state.selectedSession).toBeUndefined(); + }); +}); diff --git a/src/client/src/controllers/sessionController.reloadSelection.test.ts b/src/client/src/controllers/sessionController.reloadSelection.test.ts new file mode 100644 index 0000000..62785cf --- /dev/null +++ b/src/client/src/controllers/sessionController.reloadSelection.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; +import { initialAppState } from "../appState"; +import { ChatTranscriptStore } from "../chatTranscriptStore"; +import { SessionController } from "./sessionController"; +import { InMemorySessionSelectionMemory } from "./sessionSelection"; +import { defaultApi, emptyPage, FakeSocket, oldSession, sessionKey, sessionLookupId, status, workspace, type AppState, type MessagePage } from "./sessionController.testSupport"; + +describe("SessionController reload and selection", () => { + it("reloads the selected session from disk, discards the cached transcript, and re-fetches history", async () => { + const persistedSession = { ...oldSession, persisted: true }; + const cacheKey = sessionKey(oldSession.id); + const freshPage: MessagePage = { messages: [{ role: "assistant", content: "fresh from disk" }], start: 1, total: 2 }; + const cachedPages = new Map([[cacheKey, { messages: [{ role: "user", content: "stale cached transcript" }], start: 0, total: 2 }]]); + const reloadCalls: string[] = []; + const messageCalls: string[] = []; + let state: AppState = { + ...initialAppState(), + selectedWorkspace: workspace, + selectedSession: persistedSession, + sessions: [persistedSession], + machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } }, + }; + const api: typeof defaultApi = { + ...defaultApi, + reloadSession: (session) => { + reloadCalls.push(sessionLookupId(session)); + return Promise.resolve({ reloaded: true }); + }, + messages: (session) => { + messageCalls.push(sessionLookupId(session)); + return Promise.resolve(freshPage); + }, + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { + api, + socket: new FakeSocket(), + transcripts: new ChatTranscriptStore({ + read: (sessionId) => cachedPages.get(sessionId), + write: (sessionId, page) => { cachedPages.set(sessionId, page); }, + remove: (sessionId) => { cachedPages.delete(sessionId); }, + }), + }, + ); + + await controller.reloadSession(persistedSession); + + expect(reloadCalls).toEqual([oldSession.id]); + expect(messageCalls).toEqual([oldSession.id]); + expect(cachedPages.get(cacheKey)).toEqual(freshPage); + expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "fresh from disk" }] }]); + expect(state.messagePageStart).toBe(1); + expect(state.error).toBe(""); + }); + + it("does not reload sessions from disk when the selected machine runtime does not support it", async () => { + const persistedSession = { ...oldSession, persisted: true }; + const reloadCalls: string[] = []; + let state: AppState = { + ...initialAppState(), + selectedWorkspace: workspace, + selectedSession: persistedSession, + sessions: [persistedSession], + }; + const api: typeof defaultApi = { + ...defaultApi, + reloadSession: (session) => { + reloadCalls.push(sessionLookupId(session)); + return Promise.resolve({ reloaded: true }); + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.reloadSession(persistedSession); + + expect(reloadCalls).toEqual([]); + expect(state.error).toContain("Reloading sessions from disk requires an updated Pi-Web runtime"); + }); + + it("does not reload sessions from disk without a persisted server signal when persistence is authoritative", async () => { + const reloadCalls: string[] = []; + let state: AppState = { + ...initialAppState(), + selectedWorkspace: workspace, + selectedSession: oldSession, + sessions: [oldSession], + machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsPersistedState] } }, + }; + const api: typeof defaultApi = { + ...defaultApi, + reloadSession: (session) => { + reloadCalls.push(sessionLookupId(session)); + return Promise.resolve({ reloaded: true }); + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.reloadSession(oldSession); + await controller.reloadSession({ ...oldSession, persisted: false }); + + expect(reloadCalls).toEqual([]); + expect(state.error).toBe(""); + }); + + it("forgets archived selections when the archived section collapse clears selection", async () => { + const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] }; + const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = []; + const api: typeof defaultApi = { + ...defaultApi, + messages: () => Promise.resolve(emptyPage), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + (options) => { urlUpdates.push(options); }, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.selectSession(archivedSession, { updateUrl: false }); + expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBe(archivedSession); + + controller.clearSelectionAfterArchivedCollapse(); + + expect(state.selectedSession).toBeUndefined(); + expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBeUndefined(); + expect(urlUpdates).toEqual([undefined]); + }); +}); diff --git a/src/client/src/controllers/sessionController.sendQueue.test.ts b/src/client/src/controllers/sessionController.sendQueue.test.ts new file mode 100644 index 0000000..ee0029f --- /dev/null +++ b/src/client/src/controllers/sessionController.sendQueue.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from "vitest"; +import { initialAppState } from "../appState"; +import { SessionController } from "./sessionController"; +import { defaultApi, deferred, emptyPage, FakeSocket, oldSession, replacementSession, sessionLookupId, status, workspace, type AppState, type Deferred, type PromptAttachment, type SessionInfo } from "./sessionController.testSupport"; + +describe("SessionController send queue", () => { + it("toggles the per-session sending state around an inline attachment send and forwards attachments", async () => { + let resolvePrompt: (() => void) | undefined; + let promptArgs: { attachments?: PromptAttachment[] } | undefined; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] }; + const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }]; + const api: typeof defaultApi = { + ...defaultApi, + prompt: (_session, _text, _behavior, _machineId, sentAttachments) => new Promise<{ accepted: true }>((resolve) => { + promptArgs = { ...(sentAttachments === undefined ? {} : { attachments: sentAttachments }) }; + resolvePrompt = () => { resolve({ accepted: true }); }; + }), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const send = controller.send("look", undefined, attachments, "inline"); + const sendingDuringPrompt = state.sendingPrompts; + resolvePrompt?.(); + await send; + + expect(sendingDuringPrompt).toEqual({ [oldSession.id]: true }); + expect(state.sendingPrompts).toEqual({}); + expect(promptArgs).toEqual({ attachments }); + }); + + it("keeps the sending state scoped to the originating session when the user switches away", async () => { + let resolvePrompt: (() => void) | undefined; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession, replacementSession] }; + const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }]; + const api: typeof defaultApi = { + ...defaultApi, + prompt: () => new Promise<{ accepted: true }>((resolve) => { resolvePrompt = () => { resolve({ accepted: true }); }; }), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const send = controller.send("look", undefined, attachments, "inline"); + // While the upload is in flight, deselecting must not clear the originating + // session's sending entry, and it must stay keyed to that session only. + controller.deselectSession(); + expect(state.sendingPrompts).toEqual({ [oldSession.id]: true }); + expect(state.sendingPrompts[replacementSession.id]).toBeUndefined(); + resolvePrompt?.(); + await send; + expect(state.sendingPrompts).toEqual({}); + }); + + it("uploads to the workspace folder and rewrites the prompt for folder delivery", async () => { + let savedCalledWith: PromptAttachment[] | undefined; + let promptText: string | undefined; + let promptAttachments: PromptAttachment[] | undefined; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] }; + const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }]; + const api: typeof defaultApi = { + ...defaultApi, + saveAttachments: (_session, sent) => { savedCalledWith = sent; return Promise.resolve([{ path: ".pi-web/attachments/shot.png", mimeType: "image/png", size: 3 }]); }, + prompt: (_session, text, _behavior, _machineId, sentAttachments) => { promptText = text; promptAttachments = sentAttachments; return Promise.resolve({ accepted: true }); }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + await controller.send("check this", undefined, attachments, "folder"); + + expect(savedCalledWith).toEqual(attachments); + expect(promptText).toBe("check this\n\n@.pi-web/attachments/shot.png"); + expect(promptAttachments).toBeUndefined(); + expect(state.sendingPrompts).toEqual({}); + }); + + it("does not set the sending state for plain text messages", async () => { + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] }; + const seen: Record[] = []; + const api: typeof defaultApi = { + ...defaultApi, + prompt: () => { seen.push({ ...state.sendingPrompts }); return Promise.resolve({ accepted: true }); }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + await controller.send("hello"); + expect(seen).toEqual([{}]); + expect(state.sendingPrompts).toEqual({}); + }); + + it("sends slash commands without inserting an optimistic transcript line and toggles the sending state", async () => { + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] }; + let resolveCommand: (() => void) | undefined; + const seenDuringCommand: Record[] = []; + const api: typeof defaultApi = { + ...defaultApi, + runCommand: (_session, text) => new Promise((resolve) => { + seenDuringCommand.push({ ...state.sendingPrompts }); + resolveCommand = () => { resolve(text.startsWith("/skill") ? { type: "done" } : { type: "done", message: "stats" }); }; + }), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const run = controller.send("/skill:skill-creator"); + expect(seenDuringCommand).toEqual([{ [oldSession.id]: true }]); + // No raw command text is added to the transcript; the agent streams the + // canonical expanded message back instead. + expect(state.messages).toEqual([]); + resolveCommand?.(); + await run; + expect(state.messages).toEqual([]); + expect(state.sendingPrompts).toEqual({}); + }); + + it("queues prompt sends for a pending session start and flushes them after resolution", async () => { + const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; + const startRequest = deferred(); + const promptCalls: { sessionId: string; text: string; behavior?: "steer" | "followUp" }[] = []; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => startRequest.promise, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + prompt: (session, text, behavior) => { + promptCalls.push({ sessionId: sessionLookupId(session), text, ...(behavior === undefined ? {} : { behavior }) }); + return Promise.resolve({ accepted: true }); + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const start = controller.startSession(); + const temporaryId = state.selectedSession?.id; + if (temporaryId === undefined) throw new Error("Expected temporary session id"); + + await controller.send("first"); + await controller.send("second", "steer"); + + expect(promptCalls).toEqual([]); + expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([ + { kind: "followUp", text: "first" }, + { kind: "steer", text: "second" }, + ]); + expect(state.activity?.detail).toContain("2 queued messages"); + + startRequest.resolve(started); + await start; + + expect(promptCalls).toEqual([ + { sessionId: started.id, text: "first" }, + { sessionId: started.id, text: "second", behavior: "steer" }, + ]); + expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined(); + expect(state.clientQueuedSessionMessages[started.id]).toBeUndefined(); + expect(state.sendingPrompts).toEqual({}); + expect(state.selectedSession?.id).toBe(started.id); + }); + + it("queues slash commands, shell input, and attachments for a pending session start", async () => { + const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; + const startRequest = deferred(); + const calls: string[] = []; + const promptCalls: { text: string; attachments?: PromptAttachment[] }[] = []; + const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }]; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => startRequest.promise, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + runCommand: (session, text) => { + calls.push(`command:${sessionLookupId(session)}:${text}`); + return Promise.resolve({ type: "done" }); + }, + shell: (session, text) => { + calls.push(`shell:${sessionLookupId(session)}:${text}`); + return Promise.resolve({ accepted: true }); + }, + saveAttachments: (session, sentAttachments) => { + calls.push(`save:${sessionLookupId(session)}:${sentAttachments[0]?.name ?? ""}`); + return Promise.resolve([{ path: ".pi-web/attachments/shot.png", mimeType: "image/png", size: 3 }]); + }, + prompt: (session, text, _behavior, _machineId, sentAttachments) => { + calls.push(`prompt:${sessionLookupId(session)}:${text}`); + promptCalls.push({ text, ...(sentAttachments === undefined ? {} : { attachments: sentAttachments }) }); + return Promise.resolve({ accepted: true }); + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const start = controller.startSession(); + const temporaryId = state.selectedSession?.id; + if (temporaryId === undefined) throw new Error("Expected temporary session id"); + + await controller.send("/help"); + await controller.send("!pwd"); + await controller.send("look", undefined, attachments, "inline"); + await controller.send("save", undefined, attachments, "folder"); + + expect(calls).toEqual([]); + expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([ + { kind: "followUp", text: "/help" }, + { kind: "followUp", text: "!pwd" }, + { kind: "followUp", text: "look\n\n[1 attachment queued: shot.png]" }, + { kind: "followUp", text: "save\n\n[1 attachment queued: shot.png]" }, + ]); + + startRequest.resolve(started); + await start; + + expect(calls).toEqual([ + `command:${started.id}:/help`, + `shell:${started.id}:!pwd`, + `prompt:${started.id}:look`, + `save:${started.id}:shot.png`, + `prompt:${started.id}:save\n\n@.pi-web/attachments/shot.png`, + ]); + expect(promptCalls).toEqual([ + { text: "look", attachments }, + { text: "save\n\n@.pi-web/attachments/shot.png" }, + ]); + expect(state.clientQueuedSessionMessages[started.id]).toBeUndefined(); + }); + + it("keeps queued sends visible when backend session creation fails", async () => { + const startRequest = deferred(); + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => startRequest.promise, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const start = controller.startSession(); + const temporaryId = state.selectedSession?.id; + if (temporaryId === undefined) throw new Error("Expected temporary session id"); + await controller.send("recover me"); + + startRequest.reject(new Error("backend unavailable")); + await start; + + expect(state.selectedSession?.id).toBe(temporaryId); + expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([{ kind: "followUp", text: "recover me" }]); + expect(state.activity).toMatchObject({ sessionId: temporaryId, phase: "error", label: "Session creation failed" }); + expect(state.activity?.detail).toContain("1 queued message kept below"); + + await controller.deleteCachedNewSession(state.selectedSession); + + expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined(); + expect(state.selectedSession).toBeUndefined(); + }); + + it("keeps queued sends scoped to their originating pending start", async () => { + const firstStarted: SessionInfo = { ...oldSession, id: "started-session-1", path: "/tmp/started-session-1.jsonl" }; + const secondStarted: SessionInfo = { ...oldSession, id: "started-session-2", path: "/tmp/started-session-2.jsonl" }; + const startRequests: Deferred[] = []; + const promptCalls: { sessionId: string; text: string }[] = []; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => { + const request = deferred(); + startRequests.push(request); + return request.promise; + }, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + prompt: (session, text) => { + promptCalls.push({ sessionId: sessionLookupId(session), text }); + return Promise.resolve({ accepted: true }); + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const firstStart = controller.startSession(); + const firstTemporary = state.selectedSession; + if (firstTemporary === undefined) throw new Error("Expected first temporary session"); + const secondStart = controller.startSession(); + const secondTemporary = state.selectedSession; + if (secondTemporary === undefined) throw new Error("Expected second temporary session"); + + await controller.send("second prompt"); + await controller.selectSession(firstTemporary, { updateUrl: false }); + await controller.send("first prompt"); + + startRequests[1]?.resolve(secondStarted); + await secondStart; + + expect(promptCalls).toEqual([{ sessionId: secondStarted.id, text: "second prompt" }]); + expect(state.selectedSession?.id).toBe(firstTemporary.id); + expect(state.clientQueuedSessionMessages[secondStarted.id]).toBeUndefined(); + expect(state.clientQueuedSessionMessages[firstTemporary.id]).toEqual([{ kind: "followUp", text: "first prompt" }]); + + startRequests[0]?.resolve(firstStarted); + await firstStart; + + expect(promptCalls).toEqual([ + { sessionId: secondStarted.id, text: "second prompt" }, + { sessionId: firstStarted.id, text: "first prompt" }, + ]); + expect(state.selectedSession?.id).toBe(firstStarted.id); + expect(state.clientQueuedSessionMessages[firstStarted.id]).toBeUndefined(); + }); +}); diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts deleted file mode 100644 index ba2977a..0000000 --- a/src/client/src/controllers/sessionController.test.ts +++ /dev/null @@ -1,1614 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { api as defaultApi, type MessagePage, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type Workspace } from "../api"; -import type { SessionUiEvent } from "../sessionSocket"; -import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions"; -import { initialAppState, type AppState } from "../appState"; -import { ChatTranscriptStore } from "../chatTranscriptStore"; -import { machineSessionKey } from "../machineKeys"; -import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import { loadDraft, saveDraft } from "../promptDraftStorage"; -import { SessionController, type SessionEventSocket } from "./sessionController"; -import { InMemorySessionSelectionMemory } from "./sessionSelection"; - -class MemoryStorage implements Storage { - private readonly values = new Map(); - - get length(): number { - return this.values.size; - } - - clear(): void { - this.values.clear(); - } - - getItem(key: string): string | null { - return this.values.get(key) ?? null; - } - - key(index: number): string | null { - return Array.from(this.values.keys())[index] ?? null; - } - - removeItem(key: string): void { - this.values.delete(key); - } - - setItem(key: string, value: string): void { - this.values.set(key, value); - } -} - -class FakeSocket implements SessionEventSocket { - readonly connectedSessionIds: string[] = []; - - connect(session: SessionRef): void { - this.connectedSessionIds.push(session.id); - } - - setHandler(): void { - // Test socket does not emit events. - } - - close(): void { - // No-op. - } -} - -class EmitSocket implements SessionEventSocket { - readonly connectedSessionIds: string[] = []; - private handler: ((event: SessionUiEvent) => void) | undefined; - - connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void): void { - this.connectedSessionIds.push(session.id); - this.handler = onEvent; - } - - setHandler(onEvent: (event: SessionUiEvent) => void): void { - this.handler = onEvent; - } - - emit(event: SessionUiEvent): void { - this.handler?.(event); - } - - close(): void { - this.handler = undefined; - } -} - -const workspace: Workspace = { - id: "workspace-1", - projectId: "project-1", - path: "/repo", - label: "repo", - isMain: true, - isGitRepo: true, - isGitWorktree: false, -}; - -const oldSession: SessionInfo = { - id: "old-session", - path: "/tmp/old-session.jsonl", - cwd: "/repo", - created: "2026-05-15T00:00:00.000Z", - modified: "2026-05-15T00:00:00.000Z", - messageCount: 0, - firstMessage: "", -}; - -const replacementSession: SessionInfo = { - ...oldSession, - id: "new-session", - path: "/tmp/new-session.jsonl", -}; - -const emptyPage: MessagePage = { messages: [], start: 0, total: 0 }; - -interface Deferred { - promise: Promise; - resolve: (value: T) => void; - reject: (error: unknown) => void; -} - -function deferred(): Deferred { - let resolveDeferred: ((value: T) => void) | undefined; - let rejectDeferred: ((error: unknown) => void) | undefined; - const promise = new Promise((resolve, reject) => { - resolveDeferred = resolve; - rejectDeferred = reject; - }); - if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized"); - return { promise, resolve: resolveDeferred, reject: rejectDeferred }; -} - -function status(sessionId: string): SessionStatus { - return { - sessionId, - isStreaming: false, - isCompacting: false, - isBashRunning: false, - pendingMessageCount: 0, - queuedMessages: [], - tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - cost: 0, - }; -} - -const framesById = new Map void>(); -let nextFrameId = 1; - -// The controller coalesces status/activity/transcript updates behind -// requestAnimationFrame. The node test environment has no rAF, so install a -// controllable one: callbacks are queued and only run when a test drives a -// frame, mirroring how the browser defers them until paint. -beforeEach(() => { - framesById.clear(); - nextFrameId = 1; - vi.stubGlobal("requestAnimationFrame", (callback: () => void) => { - const id = nextFrameId++; - framesById.set(id, callback); - return id; - }); - vi.stubGlobal("cancelAnimationFrame", (id: number) => { framesById.delete(id); }); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -function runPendingAnimationFrames(): void { - const frames = Array.from(framesById.values()); - framesById.clear(); - for (const frame of frames) frame(); -} - -describe("SessionController", () => { - afterEach(() => { - Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true }); - }); - - it("coalesces rapid status updates into a single state write per frame", () => { - const setStateCalls: Partial[] = []; - let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] }; - const controller = new SessionController( - () => state, - (patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; }, - () => undefined, - undefined, - { socket: new FakeSocket() }, - ); - - controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 1 } }); - controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 2 } }); - controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 3 } }); - - // Nothing applies until the frame is flushed; last-write-wins per session. - expect(setStateCalls).toHaveLength(0); - expect(state.sessionStatuses[oldSession.id]).toBeUndefined(); - - runPendingAnimationFrames(); - - expect(setStateCalls).toHaveLength(1); - expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, messageCount: 3 }); - expect(state.status?.messageCount).toBe(3); - }); - - it("applies the latest activity per session on flush", () => { - const setStateCalls: Partial[] = []; - let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] }; - const controller = new SessionController( - () => state, - (patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; }, - () => undefined, - undefined, - { socket: new FakeSocket() }, - ); - - controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "active", label: "running tool", at: "t1" } }); - controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "idle", label: "idle", at: "t2" } }); - - expect(setStateCalls).toHaveLength(0); - - controller.flushPendingUpdates(); - - expect(state.sessionActivities[oldSession.id]).toMatchObject({ phase: "idle", label: "idle" }); - expect(state.activity?.phase).toBe("idle"); - }); - - it("coalesces status updates delivered over the per-session socket until the frame is flushed", async () => { - const socket = new EmitSocket(); - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] }; - const api: typeof defaultApi = { - ...defaultApi, - messages: () => Promise.resolve(emptyPage), - status: () => Promise.resolve(status(oldSession.id)), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket }, - ); - await controller.selectSession(oldSession, { updateUrl: false }); - - socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 7 } }); - socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 8 } }); - - // Buffered, not applied synchronously. - expect(state.sessionStatuses[oldSession.id]?.messageCount).toBeUndefined(); - - controller.flushPendingUpdates(); - - expect(state.sessionStatuses[oldSession.id]?.messageCount).toBe(8); - expect(state.status?.messageCount).toBe(8); - }); - - it("clears stale active activity when an idle status arrives", () => { - const activeActivity: SessionActivity = { sessionId: oldSession.id, phase: "active", label: "running tool", at: "2026-05-15T00:00:00.000Z" }; - let state: AppState = { - ...initialAppState(), - selectedSession: oldSession, - sessions: [oldSession], - activity: activeActivity, - sessionActivities: { [oldSession.id]: activeActivity }, - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { socket: new FakeSocket() }, - ); - - controller.applyGlobalEvent({ type: "status.update", status: status(oldSession.id) }); - controller.flushPendingUpdates(); - - expect(state.activity).toBeUndefined(); - expect(state.sessionActivities[oldSession.id]).toBeUndefined(); - expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, isStreaming: false }); - }); - - it("updates visible session message counts from live status events", () => { - let state: AppState = { - ...initialAppState(), - selectedSession: oldSession, - sessions: [oldSession], - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { socket: new FakeSocket() }, - ); - - controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 3 } }); - controller.flushPendingUpdates(); - - expect(state.sessions[0]?.messageCount).toBe(3); - expect(state.selectedSession?.messageCount).toBe(3); - }); - - it("adds a newly created session to the list when it belongs to the selected workspace", () => { - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { socket: new FakeSocket() }, - ); - const spawned: SessionInfo = { ...oldSession, id: "spawned-session", path: "/tmp/spawned-session.jsonl" }; - - controller.applyGlobalEvent({ type: "session.created", session: spawned }); - - expect(state.sessions.map((session) => session.id)).toEqual(["spawned-session", "old-session"]); - }); - - it("ignores a created session for a different workspace or a duplicate id", () => { - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { socket: new FakeSocket() }, - ); - - controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession, id: "other", cwd: "/other-repo" } }); - controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession } }); - - expect(state.sessions.map((session) => session.id)).toEqual(["old-session"]); - }); - - it("creates and selects a temporary editable session before backend start resolves", async () => { - const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; - const startRequest = deferred(); - const messageCalls: string[] = []; - const statusCalls: string[] = []; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; - const api: typeof defaultApi = { - ...defaultApi, - startSession: () => startRequest.promise, - messages: (session) => { messageCalls.push(sessionLookupId(session)); return Promise.resolve(emptyPage); }, - status: (session) => { statusCalls.push(sessionLookupId(session)); return Promise.resolve(status(sessionLookupId(session))); }, - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - const start = controller.startSession(); - const temporarySession = state.selectedSession; - - expect(temporarySession?.id).toMatch(/^pending-session-/); - expect(temporarySession?.persisted).toBe(false); - expect(state.sessions.map((session) => session.id)).toEqual([temporarySession?.id]); - expect(state.activity).toMatchObject({ sessionId: temporarySession?.id, phase: "active", label: "Creating session" }); - expect(messageCalls).toEqual([]); - expect(statusCalls).toEqual([]); - - startRequest.resolve(started); - await start; - - expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]); - expect(state.selectedSession?.id).toBe("started-session"); - expect(messageCalls).toEqual(["started-session"]); - expect(statusCalls).toEqual(["started-session"]); - }); - - it("does not duplicate a started session when its session.created broadcast races the HTTP response", async () => { - const storage = new MemoryStorage(); - Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); - const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; - const startRequest = deferred(); - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; - const socket = new FakeSocket(); - const api: typeof defaultApi = { - ...defaultApi, - startSession: () => { - // Simulate the broadcast arriving before the HTTP response resolves. - controller.applyGlobalEvent({ type: "session.created", session: started }); - return startRequest.promise; - }, - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket }, - ); - - const start = controller.startSession(); - const temporaryId = state.selectedSession?.id; - - expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]); - - startRequest.resolve(started); - await start; - - expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]); - expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true); - }); - - it("releases unrelated created-session broadcasts after pending starts settle", async () => { - const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; - const otherClientSession: SessionInfo = { ...oldSession, id: "other-client-session", path: "/tmp/other-client-session.jsonl" }; - const startRequest = deferred(); - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; - const api: typeof defaultApi = { - ...defaultApi, - startSession: () => startRequest.promise, - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - const start = controller.startSession(); - const temporaryId = state.selectedSession?.id; - controller.applyGlobalEvent({ type: "session.created", session: started }); - controller.applyGlobalEvent({ type: "session.created", session: otherClientSession }); - - expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]); - - startRequest.resolve(started); - await start; - - const sessionIds = state.sessions.map((session) => session.id); - expect(sessionIds).not.toContain(temporaryId); - expect(sessionIds.filter((id) => id === started.id)).toHaveLength(1); - expect(sessionIds.filter((id) => id === otherClientSession.id)).toHaveLength(1); - }); - - it("preserves temporary start rows across session-list refreshes before backend resolution", async () => { - const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; - const startRequest = deferred(); - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; - const api: typeof defaultApi = { - ...defaultApi, - startSession: () => startRequest.promise, - sessions: () => Promise.resolve([oldSession]), - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - const start = controller.startSession(); - const temporaryId = state.selectedSession?.id; - await controller.refreshCurrentWorkspaceSessions(); - - expect(state.sessions.map((session) => session.id)).toEqual([temporaryId, oldSession.id]); - expect(state.selectedSession?.id).toBe(temporaryId); - - startRequest.resolve(started); - await start; - - expect(state.sessions.map((session) => session.id)).toEqual([started.id, oldSession.id]); - expect(state.selectedSession?.id).toBe(started.id); - }); - - it("tracks multiple pending session starts without blocking another start", async () => { - const firstStarted: SessionInfo = { ...oldSession, id: "started-session-1", path: "/tmp/started-session-1.jsonl" }; - const secondStarted: SessionInfo = { ...oldSession, id: "started-session-2", path: "/tmp/started-session-2.jsonl" }; - const startResolvers: ((session: SessionInfo) => void)[] = []; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; - const api: typeof defaultApi = { - ...defaultApi, - startSession: () => new Promise((resolve) => { startResolvers.push(resolve); }), - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - const firstStart = controller.startSession(); - const firstTemporaryId = state.selectedSession?.id; - const secondStart = controller.startSession(); - const secondTemporaryId = state.selectedSession?.id; - - expect(startResolvers).toHaveLength(2); - expect(state.startingSessionCount).toBe(0); - expect(state.sessions.map((session) => session.id)).toEqual([secondTemporaryId, firstTemporaryId]); - expect(state.selectedSession?.id).toBe(secondTemporaryId); - expect(state.sessions.every((session) => session.persisted === false)).toBe(true); - - startResolvers[0]?.(firstStarted); - await firstStart; - - expect(state.sessions.map((session) => session.id)).toEqual([secondTemporaryId, "started-session-1"]); - expect(state.selectedSession?.id).toBe(secondTemporaryId); - - startResolvers[1]?.(secondStarted); - await secondStart; - - expect(state.startingSessionCount).toBe(0); - expect(state.sessions.map((session) => session.id)).toEqual(["started-session-2", "started-session-1"]); - expect(state.selectedSession?.id).toBe("started-session-2"); - }); - - it("moves a temporary session draft and cached-new marker to the resolved session", async () => { - const storage = new MemoryStorage(); - Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); - const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; - const startRequest = deferred(); - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; - const api: typeof defaultApi = { - ...defaultApi, - startSession: () => startRequest.promise, - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - const start = controller.startSession(); - const temporaryId = state.selectedSession?.id; - if (temporaryId === undefined) throw new Error("Expected temporary session id"); - saveDraft(sessionKey(temporaryId), "draft text"); - - startRequest.resolve(started); - await start; - - expect(loadDraft(sessionKey(temporaryId))).toBe(""); - expect(loadDraft(sessionKey(started.id))).toBe("draft text"); - expect(loadCachedNewSessions().map((session) => session.id)).toEqual([started.id]); - expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true); - }); - - it("keeps a failed temporary start selected with a discardable transient row", async () => { - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; - const api: typeof defaultApi = { - ...defaultApi, - startSession: () => Promise.reject(new Error("backend unavailable")), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - await controller.startSession(); - const temporaryId = state.selectedSession?.id; - - expect(temporaryId).toMatch(/^pending-session-/); - expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]); - expect(state.sessions[0]?.persisted).toBe(false); - expect(state.activity).toMatchObject({ sessionId: temporaryId, phase: "error", label: "Session creation failed" }); - expect(state.error).toContain("backend unavailable"); - - await controller.deleteCachedNewSession(state.sessions[0]); - - expect(state.sessions).toEqual([]); - expect(state.selectedSession).toBeUndefined(); - }); - - it("stops the backend session if a discarded pending start resolves later", async () => { - const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; - const startRequest = deferred(); - const stoppedIds: string[] = []; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; - const api: typeof defaultApi = { - ...defaultApi, - startSession: () => startRequest.promise, - stop: (session) => { stoppedIds.push(sessionLookupId(session)); return Promise.resolve({ stopped: true }); }, - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - const start = controller.startSession(); - const temporaryId = state.selectedSession?.id; - if (temporaryId === undefined) throw new Error("Expected temporary session id"); - await controller.send("queued before discard"); - expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([{ kind: "followUp", text: "queued before discard" }]); - - await controller.deleteCachedNewSession(state.selectedSession); - expect(state.sessions).toEqual([]); - expect(state.selectedSession).toBeUndefined(); - expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined(); - - startRequest.resolve(started); - await start; - - expect(stoppedIds).toEqual([started.id]); - expect(state.sessions).toEqual([]); - expect(state.selectedSession).toBeUndefined(); - }); - - it("toggles the per-session sending state around an inline attachment send and forwards attachments", async () => { - let resolvePrompt: (() => void) | undefined; - let promptArgs: { attachments?: PromptAttachment[] } | undefined; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] }; - const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }]; - const api: typeof defaultApi = { - ...defaultApi, - prompt: (_session, _text, _behavior, _machineId, sentAttachments) => new Promise<{ accepted: true }>((resolve) => { - promptArgs = { ...(sentAttachments === undefined ? {} : { attachments: sentAttachments }) }; - resolvePrompt = () => { resolve({ accepted: true }); }; - }), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - const send = controller.send("look", undefined, attachments, "inline"); - const sendingDuringPrompt = state.sendingPrompts; - resolvePrompt?.(); - await send; - - expect(sendingDuringPrompt).toEqual({ [oldSession.id]: true }); - expect(state.sendingPrompts).toEqual({}); - expect(promptArgs).toEqual({ attachments }); - }); - - it("keeps the sending state scoped to the originating session when the user switches away", async () => { - let resolvePrompt: (() => void) | undefined; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession, replacementSession] }; - const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }]; - const api: typeof defaultApi = { - ...defaultApi, - prompt: () => new Promise<{ accepted: true }>((resolve) => { resolvePrompt = () => { resolve({ accepted: true }); }; }), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - const send = controller.send("look", undefined, attachments, "inline"); - // While the upload is in flight, deselecting must not clear the originating - // session's sending entry, and it must stay keyed to that session only. - controller.deselectSession(); - expect(state.sendingPrompts).toEqual({ [oldSession.id]: true }); - expect(state.sendingPrompts[replacementSession.id]).toBeUndefined(); - resolvePrompt?.(); - await send; - expect(state.sendingPrompts).toEqual({}); - }); - - it("uploads to the workspace folder and rewrites the prompt for folder delivery", async () => { - let savedCalledWith: PromptAttachment[] | undefined; - let promptText: string | undefined; - let promptAttachments: PromptAttachment[] | undefined; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] }; - const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }]; - const api: typeof defaultApi = { - ...defaultApi, - saveAttachments: (_session, sent) => { savedCalledWith = sent; return Promise.resolve([{ path: ".pi-web/attachments/shot.png", mimeType: "image/png", size: 3 }]); }, - prompt: (_session, text, _behavior, _machineId, sentAttachments) => { promptText = text; promptAttachments = sentAttachments; return Promise.resolve({ accepted: true }); }, - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - await controller.send("check this", undefined, attachments, "folder"); - - expect(savedCalledWith).toEqual(attachments); - expect(promptText).toBe("check this\n\n@.pi-web/attachments/shot.png"); - expect(promptAttachments).toBeUndefined(); - expect(state.sendingPrompts).toEqual({}); - }); - - it("does not set the sending state for plain text messages", async () => { - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] }; - const seen: Record[] = []; - const api: typeof defaultApi = { - ...defaultApi, - prompt: () => { seen.push({ ...state.sendingPrompts }); return Promise.resolve({ accepted: true }); }, - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - await controller.send("hello"); - expect(seen).toEqual([{}]); - expect(state.sendingPrompts).toEqual({}); - }); - - it("sends slash commands without inserting an optimistic transcript line and toggles the sending state", async () => { - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] }; - let resolveCommand: (() => void) | undefined; - const seenDuringCommand: Record[] = []; - const api: typeof defaultApi = { - ...defaultApi, - runCommand: (_session, text) => new Promise((resolve) => { - seenDuringCommand.push({ ...state.sendingPrompts }); - resolveCommand = () => { resolve(text.startsWith("/skill") ? { type: "done" } : { type: "done", message: "stats" }); }; - }), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - const run = controller.send("/skill:skill-creator"); - expect(seenDuringCommand).toEqual([{ [oldSession.id]: true }]); - // No raw command text is added to the transcript; the agent streams the - // canonical expanded message back instead. - expect(state.messages).toEqual([]); - resolveCommand?.(); - await run; - expect(state.messages).toEqual([]); - expect(state.sendingPrompts).toEqual({}); - }); - - it("queues prompt sends for a pending session start and flushes them after resolution", async () => { - const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; - const startRequest = deferred(); - const promptCalls: { sessionId: string; text: string; behavior?: "steer" | "followUp" }[] = []; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; - const api: typeof defaultApi = { - ...defaultApi, - startSession: () => startRequest.promise, - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - prompt: (session, text, behavior) => { - promptCalls.push({ sessionId: sessionLookupId(session), text, ...(behavior === undefined ? {} : { behavior }) }); - return Promise.resolve({ accepted: true }); - }, - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - const start = controller.startSession(); - const temporaryId = state.selectedSession?.id; - if (temporaryId === undefined) throw new Error("Expected temporary session id"); - - await controller.send("first"); - await controller.send("second", "steer"); - - expect(promptCalls).toEqual([]); - expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([ - { kind: "followUp", text: "first" }, - { kind: "steer", text: "second" }, - ]); - expect(state.activity?.detail).toContain("2 queued messages"); - - startRequest.resolve(started); - await start; - - expect(promptCalls).toEqual([ - { sessionId: started.id, text: "first" }, - { sessionId: started.id, text: "second", behavior: "steer" }, - ]); - expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined(); - expect(state.clientQueuedSessionMessages[started.id]).toBeUndefined(); - expect(state.sendingPrompts).toEqual({}); - expect(state.selectedSession?.id).toBe(started.id); - }); - - it("queues slash commands, shell input, and attachments for a pending session start", async () => { - const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; - const startRequest = deferred(); - const calls: string[] = []; - const promptCalls: { text: string; attachments?: PromptAttachment[] }[] = []; - const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }]; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; - const api: typeof defaultApi = { - ...defaultApi, - startSession: () => startRequest.promise, - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - runCommand: (session, text) => { - calls.push(`command:${sessionLookupId(session)}:${text}`); - return Promise.resolve({ type: "done" }); - }, - shell: (session, text) => { - calls.push(`shell:${sessionLookupId(session)}:${text}`); - return Promise.resolve({ accepted: true }); - }, - saveAttachments: (session, sentAttachments) => { - calls.push(`save:${sessionLookupId(session)}:${sentAttachments[0]?.name ?? ""}`); - return Promise.resolve([{ path: ".pi-web/attachments/shot.png", mimeType: "image/png", size: 3 }]); - }, - prompt: (session, text, _behavior, _machineId, sentAttachments) => { - calls.push(`prompt:${sessionLookupId(session)}:${text}`); - promptCalls.push({ text, ...(sentAttachments === undefined ? {} : { attachments: sentAttachments }) }); - return Promise.resolve({ accepted: true }); - }, - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - const start = controller.startSession(); - const temporaryId = state.selectedSession?.id; - if (temporaryId === undefined) throw new Error("Expected temporary session id"); - - await controller.send("/help"); - await controller.send("!pwd"); - await controller.send("look", undefined, attachments, "inline"); - await controller.send("save", undefined, attachments, "folder"); - - expect(calls).toEqual([]); - expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([ - { kind: "followUp", text: "/help" }, - { kind: "followUp", text: "!pwd" }, - { kind: "followUp", text: "look\n\n[1 attachment queued: shot.png]" }, - { kind: "followUp", text: "save\n\n[1 attachment queued: shot.png]" }, - ]); - - startRequest.resolve(started); - await start; - - expect(calls).toEqual([ - `command:${started.id}:/help`, - `shell:${started.id}:!pwd`, - `prompt:${started.id}:look`, - `save:${started.id}:shot.png`, - `prompt:${started.id}:save\n\n@.pi-web/attachments/shot.png`, - ]); - expect(promptCalls).toEqual([ - { text: "look", attachments }, - { text: "save\n\n@.pi-web/attachments/shot.png" }, - ]); - expect(state.clientQueuedSessionMessages[started.id]).toBeUndefined(); - }); - - it("keeps queued sends visible when backend session creation fails", async () => { - const startRequest = deferred(); - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; - const api: typeof defaultApi = { - ...defaultApi, - startSession: () => startRequest.promise, - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - const start = controller.startSession(); - const temporaryId = state.selectedSession?.id; - if (temporaryId === undefined) throw new Error("Expected temporary session id"); - await controller.send("recover me"); - - startRequest.reject(new Error("backend unavailable")); - await start; - - expect(state.selectedSession?.id).toBe(temporaryId); - expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([{ kind: "followUp", text: "recover me" }]); - expect(state.activity).toMatchObject({ sessionId: temporaryId, phase: "error", label: "Session creation failed" }); - expect(state.activity?.detail).toContain("1 queued message kept below"); - - await controller.deleteCachedNewSession(state.selectedSession); - - expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined(); - expect(state.selectedSession).toBeUndefined(); - }); - - it("keeps queued sends scoped to their originating pending start", async () => { - const firstStarted: SessionInfo = { ...oldSession, id: "started-session-1", path: "/tmp/started-session-1.jsonl" }; - const secondStarted: SessionInfo = { ...oldSession, id: "started-session-2", path: "/tmp/started-session-2.jsonl" }; - const startRequests: Deferred[] = []; - const promptCalls: { sessionId: string; text: string }[] = []; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; - const api: typeof defaultApi = { - ...defaultApi, - startSession: () => { - const request = deferred(); - startRequests.push(request); - return request.promise; - }, - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - prompt: (session, text) => { - promptCalls.push({ sessionId: sessionLookupId(session), text }); - return Promise.resolve({ accepted: true }); - }, - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - const firstStart = controller.startSession(); - const firstTemporary = state.selectedSession; - if (firstTemporary === undefined) throw new Error("Expected first temporary session"); - const secondStart = controller.startSession(); - const secondTemporary = state.selectedSession; - if (secondTemporary === undefined) throw new Error("Expected second temporary session"); - - await controller.send("second prompt"); - await controller.selectSession(firstTemporary, { updateUrl: false }); - await controller.send("first prompt"); - - startRequests[1]?.resolve(secondStarted); - await secondStart; - - expect(promptCalls).toEqual([{ sessionId: secondStarted.id, text: "second prompt" }]); - expect(state.selectedSession?.id).toBe(firstTemporary.id); - expect(state.clientQueuedSessionMessages[secondStarted.id]).toBeUndefined(); - expect(state.clientQueuedSessionMessages[firstTemporary.id]).toEqual([{ kind: "followUp", text: "first prompt" }]); - - startRequests[0]?.resolve(firstStarted); - await firstStart; - - expect(promptCalls).toEqual([ - { sessionId: secondStarted.id, text: "second prompt" }, - { sessionId: firstStarted.id, text: "first prompt" }, - ]); - expect(state.selectedSession?.id).toBe(firstStarted.id); - expect(state.clientQueuedSessionMessages[firstStarted.id]).toBeUndefined(); - }); - - it("keeps live message count updates when a cached new session becomes persisted", async () => { - const cachedSession = markCachedNewSessionInfo(oldSession); - let resolvePrompt: (() => void) | undefined; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: cachedSession, sessions: [cachedSession] }; - const api: typeof defaultApi = { - ...defaultApi, - prompt: () => new Promise<{ accepted: true }>((resolve) => { resolvePrompt = () => { resolve({ accepted: true }); }; }), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - - const send = controller.send("hello"); - controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 1 } }); - controller.flushPendingUpdates(); - resolvePrompt?.(); - await send; - - expect(state.sessions[0]?.messageCount).toBe(1); - expect(isCachedNewSessionInfo(state.sessions[0])).toBe(false); - expect(state.selectedSession?.messageCount).toBe(1); - }); - - it("deletes transient server-reported new sessions and clears local state", async () => { - const storage = new MemoryStorage(); - Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); - const transientSession = { ...oldSession, persisted: false }; - const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true }; - const stoppedIds: string[] = []; - let state: AppState = { - ...initialAppState(), - selectedWorkspace: workspace, - selectedSession: transientSession, - sessions: [transientSession, nextSession], - sessionStatuses: { [transientSession.id]: { ...status(transientSession.id), persisted: false } }, - sessionActivities: { [transientSession.id]: { sessionId: transientSession.id, phase: "active", label: "Starting", at: "2026-05-20T00:00:00.000Z" } }, - sendingPrompts: { [transientSession.id]: true }, - }; - const api: typeof defaultApi = { - ...defaultApi, - stop: (session) => { stoppedIds.push(sessionLookupId(session)); return Promise.resolve({ stopped: true }); }, - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - undefined, - { api, socket: new FakeSocket() }, - ); - saveDraft(sessionKey(transientSession.id), "discard me"); - - await controller.deleteCachedNewSession(transientSession); - - expect(stoppedIds).toEqual([transientSession.id]); - expect(state.sessions.map((session) => session.id)).toEqual([nextSession.id]); - expect(state.sessionStatuses[transientSession.id]).toBeUndefined(); - expect(state.sessionActivities[transientSession.id]).toBeUndefined(); - expect(state.sendingPrompts[transientSession.id]).toBeUndefined(); - expect(loadDraft(sessionKey(transientSession.id))).toBe(""); - expect(state.selectedSession?.id).toBe(nextSession.id); - }); - - it("recreates missing browser-cached new sessions and moves their draft", async () => { - const storage = new MemoryStorage(); - Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); - rememberCachedNewSession(oldSession); - saveDraft(sessionKey(oldSession.id), "draft text"); - - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [markCachedNewSessionInfo(oldSession)] }; - const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = []; - const socket = new FakeSocket(); - const api: typeof defaultApi = { - ...defaultApi, - startSession: () => Promise.resolve(replacementSession), - messages: (session) => { - if (sessionLookupId(session) === oldSession.id) return Promise.reject(new Error("Session not found")); - return Promise.resolve(emptyPage); - }, - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - (options) => { urlUpdates.push(options); }, - undefined, - { api, socket }, - ); - - await controller.selectSession(markCachedNewSessionInfo(oldSession), { updateUrl: false }); - - expect(state.selectedSession?.id).toBe(replacementSession.id); - expect(state.sessions.map((session) => session.id)).toEqual([replacementSession.id]); - expect(socket.connectedSessionIds).toEqual([oldSession.id, replacementSession.id]); - expect(loadDraft(sessionKey(oldSession.id))).toBe(""); - expect(loadDraft(sessionKey(replacementSession.id))).toBe("draft text"); - expect(loadCachedNewSessions().map((session) => session.id)).toEqual([replacementSession.id]); - expect(urlUpdates).toEqual([{ replace: true }]); - }); - - it("stores command prompt drafts for replacement sessions before selecting them", async () => { - const storage = new MemoryStorage(); - Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); - - let state: AppState = { - ...initialAppState(), - selectedWorkspace: workspace, - selectedSession: oldSession, - sessions: [oldSession], - commandDialog: { type: "select", requestId: "r1", title: "Fork from message", options: [{ value: "m1", label: "fork me" }] }, - }; - const urlUpdates: unknown[] = []; - const api: typeof defaultApi = { - ...defaultApi, - respondToCommand: () => Promise.resolve({ type: "done", message: "Session forked", session: replacementSession, promptDraft: "fork me" }), - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - (options) => { urlUpdates.push(options); }, - undefined, - { api, socket: new FakeSocket() }, - ); - - await controller.respondToCommand("r1", "m1"); - - expect(state.commandDialog).toBeUndefined(); - expect(loadDraft(sessionKey(replacementSession.id))).toBe("fork me"); - }); - - it("forgets the selected active session when archiving leaves only archived sessions", async () => { - const persistedSession = { ...oldSession, persisted: true }; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession] }; - const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = []; - const api: typeof defaultApi = { - ...defaultApi, - archive: () => Promise.resolve({ archived: true }), - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - (options) => { urlUpdates.push(options); }, - new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, - ); - - await controller.selectSession(persistedSession, { updateUrl: false }); - await controller.archiveSession(); - - expect(state.selectedSession).toBeUndefined(); - expect(state.sessions).toHaveLength(1); - expect(state.sessions[0]).toMatchObject({ ...oldSession, archived: true }); - expect(typeof state.sessions[0]?.archivedAt).toBe("string"); - expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBeUndefined(); - expect(urlUpdates).toEqual([undefined]); - }); - - it("archives legacy sessions when persistence support is not advertised", async () => { - const legacySession = { ...oldSession }; - const archivedIds: string[] = []; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: legacySession, sessions: [legacySession] }; - const api: typeof defaultApi = { - ...defaultApi, - archive: (session) => { - archivedIds.push(sessionLookupId(session)); - return Promise.resolve({ archived: true }); - }, - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, - ); - - await controller.archiveSession(legacySession); - - expect(archivedIds).toEqual([legacySession.id]); - expect(state.sessions[0]).toMatchObject({ id: legacySession.id, archived: true }); - }); - - it("archives selected session descendants and selects the next active session", async () => { - const persistedSession = { ...oldSession, persisted: true }; - const childSession = { ...oldSession, id: "child-session", path: "/tmp/child-session.jsonl", parentSessionPath: persistedSession.path, persisted: true }; - const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true }; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession, childSession, nextSession] }; - const api: typeof defaultApi = { - ...defaultApi, - archiveWithDescendants: () => Promise.resolve({ archived: true, sessionIds: [persistedSession.id, childSession.id], archivedCount: 2, skippedAlreadyArchivedCount: 0 }), - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, - ); - - await controller.selectSession(persistedSession, { updateUrl: false }); - await controller.archiveSessionWithDescendants(persistedSession); - - expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true }); - expect(state.sessions.find((session) => session.id === childSession.id)).toMatchObject({ archived: true }); - expect(state.selectedSession?.id).toBe(nextSession.id); - }); - - it("archives selected sessions in bulk", async () => { - const persistedSession = { ...oldSession, persisted: true }; - const secondSession = { ...oldSession, id: "second-session", path: "/tmp/second-session.jsonl", persisted: true }; - const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true }; - const archivedIds: string[] = []; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession, secondSession, nextSession] }; - const api: typeof defaultApi = { - ...defaultApi, - archive: (session) => { - archivedIds.push(sessionLookupId(session)); - return Promise.resolve({ archived: true }); - }, - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, - ); - - await controller.selectSession(persistedSession, { updateUrl: false }); - await controller.archiveSessions([persistedSession, secondSession]); - - expect(archivedIds).toEqual([oldSession.id, secondSession.id]); - expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true }); - expect(state.sessions.find((session) => session.id === secondSession.id)).toMatchObject({ archived: true }); - expect(state.selectedSession?.id).toBe(nextSession.id); - }); - - it("uses true bulk archive when the selected runtime supports it and applies partial failures", async () => { - const persistedSession = { ...oldSession, persisted: true }; - const failedSession = { ...oldSession, id: "failed-session", path: "/tmp/failed-session.jsonl", persisted: true }; - const archiveCalls: { ids: string[]; machineId: string }[] = []; - let state: AppState = { - ...initialAppState(), - selectedWorkspace: workspace, - sessions: [persistedSession, failedSession], - machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsBulkMutations] } }, - }; - const api: typeof defaultApi = { - ...defaultApi, - archiveMany: (sessions, machineId) => { - archiveCalls.push({ ids: sessions.map(sessionLookupId), machineId: machineId ?? "local" }); - return Promise.resolve({ archived: true, archivedSessionIds: [persistedSession.id], failures: [{ sessionId: failedSession.id, error: "busy" }], generatedAt: "now" }); - }, - archive: () => { throw new Error("single archive should not be used"); }, - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, - ); - - await controller.selectSession(persistedSession, { updateUrl: false }); - await controller.archiveSessions([persistedSession, failedSession]); - - expect(archiveCalls).toEqual([{ ids: [oldSession.id, failedSession.id], machineId: "local" }]); - expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true }); - expect(state.sessions.find((session) => session.id === failedSession.id)?.archived).toBeUndefined(); - expect(state.selectedSession?.id).toBe(failedSession.id); - expect(state.error).toBe("Archive failed for 1 session: failed-session: busy"); - }); - - it("throttles per-session archive fallback when bulk mutations are unsupported", async () => { - const sessions = Array.from({ length: 6 }, (_value, index) => ({ ...oldSession, id: `session-${String(index)}`, path: `/tmp/session-${String(index)}.jsonl`, persisted: true })); - const resolvers: (() => void)[] = []; - const startedIds: string[] = []; - let activeCount = 0; - let maxActiveCount = 0; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions }; - const api: typeof defaultApi = { - ...defaultApi, - archive: (session) => new Promise((resolve) => { - activeCount += 1; - maxActiveCount = Math.max(maxActiveCount, activeCount); - startedIds.push(sessionLookupId(session)); - resolvers.push(() => { - activeCount -= 1; - resolve({ archived: true }); - }); - }), - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, - ); - - const archive = controller.archiveSessions(sessions); - await Promise.resolve(); - - expect(startedIds).toHaveLength(4); - resolvers.shift()?.(); - await Promise.resolve(); - await Promise.resolve(); - expect(startedIds).toHaveLength(5); - for (const resolve of resolvers.splice(0)) resolve(); - await Promise.resolve(); - await Promise.resolve(); - for (const resolve of resolvers.splice(0)) resolve(); - await archive; - - expect(maxActiveCount).toBe(4); - expect(state.sessions.every((session) => session.archived === true)).toBe(true); - }); - - it("deletes selected archived sessions in bulk and selects the next current session", async () => { - const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; - const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" }; - const deletedIds: string[] = []; - let state: AppState = { - ...initialAppState(), - selectedWorkspace: workspace, - selectedSession: archivedSession, - sessions: [archivedSession, nextSession], - machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] } }, - }; - const api: typeof defaultApi = { - ...defaultApi, - deleteArchived: (session) => { - deletedIds.push(sessionLookupId(session)); - return Promise.resolve({ deleted: true }); - }, - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, - ); - - await controller.deleteArchivedSessions([archivedSession]); - - expect(deletedIds).toEqual([archivedSession.id]); - expect(state.sessions.map((session) => session.id)).toEqual([nextSession.id]); - expect(state.selectedSession?.id).toBe(nextSession.id); - }); - - it("uses true bulk delete when supported and keeps partial failures visible", async () => { - const deletedSession = { ...oldSession, archived: true, archivedAt: "later" }; - const failedSession = { ...oldSession, id: "failed-archived", path: "/tmp/failed-archived.jsonl", archived: true, archivedAt: "later" }; - const deleteCalls: { ids: string[]; machineId: string }[] = []; - let state: AppState = { - ...initialAppState(), - selectedWorkspace: workspace, - selectedSession: deletedSession, - sessions: [deletedSession, failedSession], - machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsBulkMutations] } }, - }; - const api: typeof defaultApi = { - ...defaultApi, - deleteArchivedMany: (sessions, machineId) => { - deleteCalls.push({ ids: sessions.map(sessionLookupId), machineId: machineId ?? "local" }); - return Promise.resolve({ deleted: true, deletedSessionIds: [deletedSession.id], failures: [{ sessionId: failedSession.id, error: "busy" }], generatedAt: "now" }); - }, - deleteArchived: () => { throw new Error("single delete should not be used"); }, - messages: () => Promise.resolve(emptyPage), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, - ); - - await controller.deleteArchivedSessions([deletedSession, failedSession]); - - expect(deleteCalls).toEqual([{ ids: [deletedSession.id, failedSession.id], machineId: "local" }]); - expect(state.sessions.map((session) => session.id)).toEqual([failedSession.id]); - expect(state.selectedSession?.id).toBe(failedSession.id); - expect(state.error).toBe("Delete failed for 1 session: failed-archived: busy"); - }); - - it("applies cleanup execution results and refreshes the current workspace sessions", async () => { - const archivedAt = "2026-06-25T12:00:00.000Z"; - const deletedArchived = { ...oldSession, id: "deleted-archived", path: "/tmp/deleted-archived.jsonl", archived: true, archivedAt: "2026-05-01T00:00:00.000Z" }; - const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" }; - const refreshedArchived = { ...oldSession, archived: true, archivedAt }; - const sessionsCalls: { cwd: string; machineId: string }[] = []; - let state: AppState = { - ...initialAppState(), - selectedWorkspace: workspace, - selectedSession: oldSession, - sessions: [oldSession, deletedArchived, nextSession], - sessionStatuses: { [oldSession.id]: status(oldSession.id), [deletedArchived.id]: status(deletedArchived.id), [nextSession.id]: status(nextSession.id) }, - sessionActivities: { [oldSession.id]: { sessionId: oldSession.id, phase: "idle", label: "idle", at: archivedAt } }, - }; - const api: typeof defaultApi = { - ...defaultApi, - sessions: (cwd, machineId) => { - sessionsCalls.push({ cwd, machineId: machineId ?? "local" }); - return Promise.resolve([refreshedArchived, nextSession]); - }, - messages: () => Promise.resolve(emptyPage), - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, - ); - - await controller.applySessionCleanupResult({ - generatedAt: archivedAt, - thresholds: { archiveIdleDays: 30, deleteArchivedDays: 60 }, - projects: [{ cwd: workspace.path, archiveCount: 1, deleteCount: 1 }], - totals: { archiveCount: 1, deleteCount: 1 }, - archivedSessionIds: [oldSession.id], - deletedSessionIds: [deletedArchived.id], - }); - - expect(sessionsCalls).toEqual([{ cwd: workspace.path, machineId: "local" }]); - expect(state.sessions.map((session) => session.id)).toEqual([oldSession.id, nextSession.id]); - expect(state.sessions[0]).toMatchObject({ id: oldSession.id, archived: true, archivedAt }); - expect(state.selectedSession?.id).toBe(nextSession.id); - expect(state.sessionStatuses[oldSession.id]).toBeUndefined(); - expect(state.sessionStatuses[deletedArchived.id]).toBeUndefined(); - expect(state.sessionActivities[oldSession.id]).toBeUndefined(); - }); - - it("does not delete archived sessions when the selected machine runtime reports no support", async () => { - const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; - const deletedIds: string[] = []; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession], machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [] } } }; - const api: typeof defaultApi = { - ...defaultApi, - deleteArchived: (session) => { - deletedIds.push(sessionLookupId(session)); - return Promise.resolve({ deleted: true }); - }, - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, - ); - - await controller.deleteArchivedSessions([archivedSession]); - - expect(deletedIds).toEqual([]); - expect(state.sessions).toEqual([archivedSession]); - expect(state.error).toContain("requires an updated Pi-Web runtime"); - }); - - it("allows legacy archived-session deletion when runtime support is unknown", async () => { - const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; - const deletedIds: string[] = []; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: archivedSession, sessions: [archivedSession] }; - const api: typeof defaultApi = { - ...defaultApi, - deleteArchived: (session) => { - deletedIds.push(sessionLookupId(session)); - return Promise.resolve({ deleted: true }); - }, - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, - ); - - await controller.deleteArchivedSessions([archivedSession]); - - expect(deletedIds).toEqual([archivedSession.id]); - expect(state.sessions).toEqual([]); - expect(state.error).toBe(""); - }); - - it("reloads the selected session from disk, discards the cached transcript, and re-fetches history", async () => { - const persistedSession = { ...oldSession, persisted: true }; - const cacheKey = sessionKey(oldSession.id); - const freshPage: MessagePage = { messages: [{ role: "assistant", content: "fresh from disk" }], start: 1, total: 2 }; - const cachedPages = new Map([[cacheKey, { messages: [{ role: "user", content: "stale cached transcript" }], start: 0, total: 2 }]]); - const reloadCalls: string[] = []; - const messageCalls: string[] = []; - let state: AppState = { - ...initialAppState(), - selectedWorkspace: workspace, - selectedSession: persistedSession, - sessions: [persistedSession], - machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } }, - }; - const api: typeof defaultApi = { - ...defaultApi, - reloadSession: (session) => { - reloadCalls.push(sessionLookupId(session)); - return Promise.resolve({ reloaded: true }); - }, - messages: (session) => { - messageCalls.push(sessionLookupId(session)); - return Promise.resolve(freshPage); - }, - status: (session) => Promise.resolve(status(sessionLookupId(session))), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - new InMemorySessionSelectionMemory(), - { - api, - socket: new FakeSocket(), - transcripts: new ChatTranscriptStore({ - read: (sessionId) => cachedPages.get(sessionId), - write: (sessionId, page) => { cachedPages.set(sessionId, page); }, - remove: (sessionId) => { cachedPages.delete(sessionId); }, - }), - }, - ); - - await controller.reloadSession(persistedSession); - - expect(reloadCalls).toEqual([oldSession.id]); - expect(messageCalls).toEqual([oldSession.id]); - expect(cachedPages.get(cacheKey)).toEqual(freshPage); - expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "fresh from disk" }] }]); - expect(state.messagePageStart).toBe(1); - expect(state.error).toBe(""); - }); - - it("does not reload sessions from disk when the selected machine runtime does not support it", async () => { - const persistedSession = { ...oldSession, persisted: true }; - const reloadCalls: string[] = []; - let state: AppState = { - ...initialAppState(), - selectedWorkspace: workspace, - selectedSession: persistedSession, - sessions: [persistedSession], - }; - const api: typeof defaultApi = { - ...defaultApi, - reloadSession: (session) => { - reloadCalls.push(sessionLookupId(session)); - return Promise.resolve({ reloaded: true }); - }, - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, - ); - - await controller.reloadSession(persistedSession); - - expect(reloadCalls).toEqual([]); - expect(state.error).toContain("Reloading sessions from disk requires an updated Pi-Web runtime"); - }); - - it("does not reload sessions from disk without a persisted server signal when persistence is authoritative", async () => { - const reloadCalls: string[] = []; - let state: AppState = { - ...initialAppState(), - selectedWorkspace: workspace, - selectedSession: oldSession, - sessions: [oldSession], - machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsPersistedState] } }, - }; - const api: typeof defaultApi = { - ...defaultApi, - reloadSession: (session) => { - reloadCalls.push(sessionLookupId(session)); - return Promise.resolve({ reloaded: true }); - }, - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - () => undefined, - new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, - ); - - await controller.reloadSession(oldSession); - await controller.reloadSession({ ...oldSession, persisted: false }); - - expect(reloadCalls).toEqual([]); - expect(state.error).toBe(""); - }); - - it("forgets archived selections when the archived section collapse clears selection", async () => { - const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; - let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] }; - const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = []; - const api: typeof defaultApi = { - ...defaultApi, - messages: () => Promise.resolve(emptyPage), - }; - const controller = new SessionController( - () => state, - (patch) => { state = { ...state, ...patch }; }, - (options) => { urlUpdates.push(options); }, - new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, - ); - - await controller.selectSession(archivedSession, { updateUrl: false }); - expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBe(archivedSession); - - controller.clearSelectionAfterArchivedCollapse(); - - expect(state.selectedSession).toBeUndefined(); - expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBeUndefined(); - expect(urlUpdates).toEqual([undefined]); - }); -}); - -function sessionKey(sessionId: string): string { - return machineSessionKey("local", sessionId); -} - -function sessionLookupId(session: string | SessionRef): string { - return typeof session === "string" ? session : session.id; -} diff --git a/src/client/src/controllers/sessionController.testSupport.ts b/src/client/src/controllers/sessionController.testSupport.ts new file mode 100644 index 0000000..e849425 --- /dev/null +++ b/src/client/src/controllers/sessionController.testSupport.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, vi } from "vitest"; +import type { MessagePage, SessionInfo, SessionRef, SessionStatus, Workspace } from "../api"; +import { machineSessionKey } from "../machineKeys"; +import type { SessionUiEvent } from "../sessionSocket"; +import type { SessionEventSocket } from "./sessionController"; + +export { api as defaultApi } from "../api"; +export type { MessagePage, PromptAttachment, SessionActivity, SessionInfo, SessionRef, SessionStatus, Workspace } from "../api"; +export type { AppState } from "../appState"; + +export class MemoryStorage implements Storage { + private readonly values = new Map(); + + get length(): number { + return this.values.size; + } + + clear(): void { + this.values.clear(); + } + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + key(index: number): string | null { + return Array.from(this.values.keys())[index] ?? null; + } + + removeItem(key: string): void { + this.values.delete(key); + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } +} + +export class FakeSocket implements SessionEventSocket { + readonly connectedSessionIds: string[] = []; + + connect(session: SessionRef): void { + this.connectedSessionIds.push(session.id); + } + + setHandler(): void { + // Test socket does not emit events. + } + + close(): void { + // No-op. + } +} + +export class EmitSocket implements SessionEventSocket { + readonly connectedSessionIds: string[] = []; + private handler: ((event: SessionUiEvent) => void) | undefined; + + connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void): void { + this.connectedSessionIds.push(session.id); + this.handler = onEvent; + } + + setHandler(onEvent: (event: SessionUiEvent) => void): void { + this.handler = onEvent; + } + + emit(event: SessionUiEvent): void { + this.handler?.(event); + } + + close(): void { + this.handler = undefined; + } +} + +export const workspace: Workspace = { + id: "workspace-1", + projectId: "project-1", + path: "/repo", + label: "repo", + isMain: true, + isGitRepo: true, + isGitWorktree: false, +}; + +export const oldSession: SessionInfo = { + id: "old-session", + path: "/tmp/old-session.jsonl", + cwd: "/repo", + created: "2026-05-15T00:00:00.000Z", + modified: "2026-05-15T00:00:00.000Z", + messageCount: 0, + firstMessage: "", +}; + +export const replacementSession: SessionInfo = { + ...oldSession, + id: "new-session", + path: "/tmp/new-session.jsonl", +}; + +export const emptyPage: MessagePage = { messages: [], start: 0, total: 0 }; + +export interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +export function deferred(): Deferred { + let resolveDeferred: ((value: T) => void) | undefined; + let rejectDeferred: ((error: unknown) => void) | undefined; + const promise = new Promise((resolve, reject) => { + resolveDeferred = resolve; + rejectDeferred = reject; + }); + if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized"); + return { promise, resolve: resolveDeferred, reject: rejectDeferred }; +} + +export function status(sessionId: string): SessionStatus { + return { + sessionId, + isStreaming: false, + isCompacting: false, + isBashRunning: false, + pendingMessageCount: 0, + queuedMessages: [], + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost: 0, + }; +} + +const framesById = new Map void>(); +let nextFrameId = 1; + +// The controller coalesces status/activity/transcript updates behind +// requestAnimationFrame. The node test environment has no rAF, so install a +// controllable one: callbacks are queued and only run when a test drives a +// frame, mirroring how the browser defers them until paint. +beforeEach(() => { + framesById.clear(); + nextFrameId = 1; + vi.stubGlobal("requestAnimationFrame", (callback: () => void) => { + const id = nextFrameId++; + framesById.set(id, callback); + return id; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => { framesById.delete(id); }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true }); +}); + +export function runPendingAnimationFrames(): void { + const frames = Array.from(framesById.values()); + framesById.clear(); + for (const frame of frames) frame(); +} + +export function sessionKey(sessionId: string): string { + return machineSessionKey("local", sessionId); +} + +export function sessionLookupId(session: string | SessionRef): string { + return typeof session === "string" ? session : session.id; +} From 386386d2b55a0008fccf9730feb729d43848dd35 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 4 Jul 2026 23:53:58 +0200 Subject: [PATCH 059/111] test(sessions): split pi session service specs --- .../piSessionService.archiveCleanup.test.ts | 358 +++ .../piSessionService.lifecycle.test.ts | 386 +++ .../piSessionService.promptQueue.test.ts | 299 +++ .../piSessionService.spawnSession.test.ts | 85 + .../piSessionService.spawnSubsession.test.ts | 844 +++++++ src/server/sessions/piSessionService.test.ts | 2105 ----------------- .../sessions/piSessionService.testSupport.ts | 162 ++ 7 files changed, 2134 insertions(+), 2105 deletions(-) create mode 100644 src/server/sessions/piSessionService.archiveCleanup.test.ts create mode 100644 src/server/sessions/piSessionService.lifecycle.test.ts create mode 100644 src/server/sessions/piSessionService.promptQueue.test.ts create mode 100644 src/server/sessions/piSessionService.spawnSession.test.ts create mode 100644 src/server/sessions/piSessionService.spawnSubsession.test.ts delete mode 100644 src/server/sessions/piSessionService.test.ts create mode 100644 src/server/sessions/piSessionService.testSupport.ts diff --git a/src/server/sessions/piSessionService.archiveCleanup.test.ts b/src/server/sessions/piSessionService.archiveCleanup.test.ts new file mode 100644 index 0000000..3116a66 --- /dev/null +++ b/src/server/sessions/piSessionService.archiveCleanup.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, it, vi } from "vitest"; +import { PiSessionService } from "./piSessionService.js"; +import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js"; + +describe("PiSessionService archive and cleanup", () => { + it("archives a session subtree within the root workspace", async () => { + const archivedInputs: string[] = []; + const root = sessionRecord("root"); + const directChild = { ...sessionRecord("direct-child"), path: "/sessions/direct-child.jsonl", parentSessionPath: root.path }; + const archivedChild = { ...sessionRecord("archived-child"), path: "/sessions/archived-child.jsonl", parentSessionPath: root.path }; + const grandchild = { ...sessionRecord("grandchild"), path: "/sessions/grandchild.jsonl", parentSessionPath: archivedChild.path }; + const otherWorkspaceChild = { ...sessionRecord("other-child", "/other"), path: "/sessions/other-child.jsonl", parentSessionPath: root.path }; + const fake = fakeRuntime("root", { sessionFile: root.path }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + archiveStore: { + list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]), + get: () => Promise.resolve(undefined), + archive: (input) => { + archivedInputs.push(input.sessionId); + return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }); + }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager(), + list: (cwd) => Promise.resolve(cwd === "/workspace" ? [root, directChild, archivedChild, grandchild] : [otherWorkspaceChild]), + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + await expect(service.archiveTree(sessionRef("root"))).resolves.toEqual({ + archived: true, + sessionIds: ["root", "direct-child", "grandchild"], + archivedCount: 3, + skippedAlreadyArchivedCount: 1, + }); + expect(archivedInputs).toEqual(["root", "direct-child", "grandchild"]); + + await service.dispose(); + }); + + it("permanently deletes archived sessions through the archive store", async () => { + const deletedSessionIds: string[] = []; + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: { + list: () => Promise.resolve([]), + get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) + ? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" } + : undefined), + archive: () => { throw new Error("archive should not be called for records that already have archive files"); }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + deleteArchived: (sessionId) => { + deletedSessionIds.push(sessionId); + return Promise.resolve(); + }, + }, + sessionManager: sessionGateway([sessionRecord("active")]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.deleteArchived("arch")).resolves.toBeUndefined(); + await expect(service.deleteArchived("active")).rejects.toThrow("Archived session not found"); + + expect(deletedSessionIds).toEqual(["archived"]); + await service.dispose(); + }); + + it("bulk archives inactive sessions by cwd without opening runtimes", async () => { + const recordsByCwd = new Map([ + ["/one", [sessionRecord("a", "/one"), sessionRecord("b", "/one")]], + ["/two", [sessionRecord("c", "/two")]], + ]); + const listCalls: string[] = []; + const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); }); + const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: { + list: () => Promise.resolve([]), + get: () => Promise.resolve(undefined), + archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), + archiveMany, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager(), + list: (cwd) => { + listCalls.push(cwd); + return Promise.resolve(recordsByCwd.get(cwd) ?? []); + }, + open, + }, + heartbeatIntervalMs: 60_000, + }); + + const result = await service.archiveMany([{ id: "a", cwd: "/one" }, { id: "b", cwd: "/one" }, { id: "c", cwd: "/two" }]); + + expect(result).toMatchObject({ archived: true, archivedSessionIds: ["a", "b", "c"], failures: [] }); + expect(listCalls).toEqual(["/one", "/two"]); + expect(open).not.toHaveBeenCalled(); + expect(archiveMany).toHaveBeenCalledTimes(1); + expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["a", "b", "c"]); + await service.dispose(); + }); + + it("bulk archive reports per-session failures without aborting other archives", async () => { + const busy = fakeRuntime("busy", { isStreaming: true }); + let createCalls = 0; + const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + createCalls += 1; + return Promise.resolve(busy.runtime); + }, + archiveStore: { + list: () => Promise.resolve([]), + get: () => Promise.resolve(undefined), + archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), + archiveMany, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([sessionRecord("busy"), sessionRecord("ok")]), + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("busy")); + const result = await service.archiveMany([{ id: "busy", cwd: "/workspace" }, { id: "ok", cwd: "/workspace" }, { id: "missing", cwd: "/workspace" }]); + + expect(createCalls).toBe(1); + expect(busy.calls.abort).toBe(0); + expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["ok"]); + expect(result.archivedSessionIds).toEqual(["ok"]); + expect(result.failures).toEqual([ + { sessionId: "busy", error: "Stop current session activity before archiving" }, + { sessionId: "missing", error: "Session not found" }, + ]); + await service.dispose(); + }); + + it("bulk deletes only archived sessions and skips busy active archived runtimes", async () => { + const busyRecord = { sessionId: "busy-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/busy.jsonl" }; + const idleRecord = { sessionId: "idle-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/idle.jsonl" }; + const busy = fakeRuntime("busy-archived", { isStreaming: true }); + const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(busy.runtime), + archiveStore: { + list: () => Promise.resolve([busyRecord, idleRecord]), + get: (sessionId) => Promise.resolve(sessionId === "busy-archived" ? busyRecord : undefined), + archive: () => { throw new Error("archive should not be called for records that already have archive files"); }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + deleteArchived: () => Promise.resolve(), + deleteArchivedMany, + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([sessionRecord("unarchived")]), + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("busy-archived")); + const result = await service.deleteArchivedMany([{ id: "busy-archived", cwd: "/workspace" }, { id: "idle-archived", cwd: "/workspace" }, { id: "unarchived", cwd: "/workspace" }]); + + expect(busy.calls.abort).toBe(0); + expect(deleteArchivedMany).toHaveBeenCalledWith(["idle-archived"]); + expect(result.deletedSessionIds).toEqual(["idle-archived"]); + expect(result.failures).toEqual([ + { sessionId: "busy-archived", error: "Stop current session activity before deleting archived session" }, + { sessionId: "unarchived", error: "Archived session not found" }, + ]); + await service.dispose(); + }); + + it("bulk delete moves legacy archived records with one workspace scan before deleting", async () => { + const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` })))); + const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); + const listCalls: string[] = []; + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: { + list: () => Promise.resolve([ + { sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }, + { sessionId: "legacy-b", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }, + { sessionId: "moved", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/moved.jsonl" }, + ]), + get: () => Promise.resolve(undefined), + archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), + archiveMany, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + deleteArchived: () => Promise.resolve(), + deleteArchivedMany, + }, + sessionManager: { + create: () => fakeSessionManager(), + list: (cwd) => { + listCalls.push(cwd); + return Promise.resolve([sessionRecord("legacy-a"), sessionRecord("legacy-b"), sessionRecord("unarchived")]); + }, + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + const result = await service.deleteArchivedMany([{ id: "legacy-a", cwd: "/workspace" }, { id: "legacy-b", cwd: "/workspace" }, { id: "moved", cwd: "/workspace" }]); + + expect(listCalls).toEqual(["/workspace"]); + expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]); + expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b", "moved"]); + expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b", "moved"]); + expect(result.failures).toEqual([]); + await service.dispose(); + }); + + it("previews session cleanup without mutating and executes a recomputed plan", async () => { + const archivedInputs: string[] = []; + const deletedSessionIds: string[] = []; + let listAllCalls = 0; + const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" }; + const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + now: () => new Date("2026-06-25T00:00:00.000Z"), + archiveStore: { + list: () => Promise.resolve([archived, otherArchived]), + get: () => Promise.resolve(undefined), + archive: () => Promise.reject(new Error("cleanup should use archiveMany")), + archiveMany: (inputs) => { + archivedInputs.push(...inputs.map((input) => input.sessionId)); + return Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }))); + }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")), + deleteArchivedMany: (sessionIds) => { + deletedSessionIds.push(...sessionIds); + return Promise.resolve([...sessionIds]); + }, + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([]), + listAll: () => { + listAllCalls += 1; + return Promise.resolve([ + listAllCalls === 1 ? sessionRecord("preview-only", "/old-project") : sessionRecord("execute-only", "/old-project"), + listAllCalls === 1 ? sessionRecord("preview-other", "/other-project") : sessionRecord("execute-other", "/other-project"), + ]); + }, + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + const preview = await service.cleanupPreview({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); + expect(preview.totals).toEqual({ archiveCount: 1, deleteCount: 1 }); + expect(preview.projects).toEqual([{ cwd: "/old-project", archiveCount: 1, deleteCount: 1 }]); + expect(archivedInputs).toEqual([]); + expect(deletedSessionIds).toEqual([]); + + const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); + expect(result.archivedSessionIds).toEqual(["execute-only"]); + expect(result.deletedSessionIds).toEqual(["archived-old"]); + expect(archivedInputs).toEqual(["execute-only"]); + expect(deletedSessionIds).toEqual(["archived-old"]); + + await service.dispose(); + }); + + it("moves legacy cleanup delete records with one workspace scan before batch deleting", async () => { + const listCalls: string[] = []; + const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` })))); + const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); + const service = new PiSessionService(new CapturingSessionEventHub(), { + now: () => new Date("2026-06-25T00:00:00.000Z"), + archiveStore: { + list: () => Promise.resolve([ + { sessionId: "legacy-a", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" }, + { sessionId: "legacy-b", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" }, + ]), + get: () => Promise.resolve(undefined), + archive: () => Promise.reject(new Error("cleanup should use archiveMany")), + archiveMany, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")), + deleteArchivedMany, + }, + sessionManager: { + create: () => fakeSessionManager(), + list: (cwd) => { + listCalls.push(cwd); + return Promise.resolve([sessionRecord("legacy-a", cwd), sessionRecord("legacy-b", cwd)]); + }, + listAll: () => Promise.resolve([]), + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + const result = await service.cleanup({ thresholds: { deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); + + expect(listCalls).toEqual(["/old-project"]); + expect(archiveMany).toHaveBeenCalledTimes(1); + expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]); + expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b"]); + expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b"]); + + await service.dispose(); + }); + + it("skips busy active sessions during cleanup execution", async () => { + const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" }); + const archivedInputs: string[] = []; + const service = new PiSessionService(new CapturingSessionEventHub(), { + now: () => new Date("2026-06-25T00:00:00.000Z"), + createAgentRuntime: runtimeCreator(fake.runtime), + archiveStore: { + list: () => Promise.resolve([]), + get: () => Promise.resolve(undefined), + archive: (input) => { + archivedInputs.push(input.sessionId); + return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }); + }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager("/old-project"), + list: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]), + listAll: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]), + open: () => fakeSessionManager("/old-project"), + }, + heartbeatIntervalMs: 60_000, + }); + + await service.status("busy-open"); + const result = await service.cleanup({ thresholds: { archiveIdleDays: 1 } }); + + expect(result.archivedSessionIds).toEqual([]); + expect(result.skippedBusySessionIds).toEqual(["busy-open"]); + expect(archivedInputs).toEqual([]); + expect(fake.calls.abort).toBe(0); + + await service.dispose(); + }); + +}); diff --git a/src/server/sessions/piSessionService.lifecycle.test.ts b/src/server/sessions/piSessionService.lifecycle.test.ts new file mode 100644 index 0000000..12afa09 --- /dev/null +++ b/src/server/sessions/piSessionService.lifecycle.test.ts @@ -0,0 +1,386 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; +import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js"; + +describe("PiSessionService lifecycle, listing, and reload", () => { + it("starts sessions through an injected runtime creator", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime(); + let createCalls = 0; + const createAgentRuntime: RuntimeCreator = async () => { + createCalls += 1; + await Promise.resolve(); + return fake.runtime; + }; + const service = new PiSessionService(hub, { + createAgentRuntime, + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + + const session = await service.start("/workspace"); + + expect(createCalls).toBe(1); + expect(fake.calls.bindExtensions).toHaveLength(1); + expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 }); + expect(service.activeCount()).toBe(1); + expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true); + expect(hub.globalEvents.some((event) => event.type === "session.created" && event.session.id === "session-1" && event.session.cwd === "/workspace")).toBe(true); + + await service.dispose(); + expect(fake.calls.abort).toBe(1); + expect(fake.calls.dispose).toBe(1); + }); + + it("reports persistence from actual session-file existence for fresh active sessions", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-web-persisted-")); + const sessionFile = join(dir, "new-session.jsonl"); + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("new-session", { sessionFile }); + let service: PiSessionService | undefined; + try { + service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + + const session = await service.start("/workspace"); + const createdEvent = hub.globalEvents.find((event) => event.type === "session.created"); + + expect(session).toMatchObject({ id: "new-session", path: sessionFile, persisted: false }); + expect(createdEvent).toMatchObject({ type: "session.created", session: { id: "new-session", persisted: false } }); + await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: false }); + + await writeFile(sessionFile, '{"type":"session","id":"new-session"}\n', "utf8"); + + await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: true }); + } finally { + await service?.dispose(); + await rm(dir, { recursive: true, force: true }); + } + }); + + it("opens legacy id-only lookups from the default session store gateway", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("legacy-session"); + const open = vi.fn(() => fakeSessionManager()); + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([]), + listAll: () => Promise.resolve([sessionRecord("legacy-session")]), + open, + }, + heartbeatIntervalMs: 60_000, + }); + + await expect(service.status("legacy")).resolves.toMatchObject({ sessionId: "legacy-session" }); + expect(open).toHaveBeenCalledWith("/sessions/legacy-session.jsonl"); + + await service.dispose(); + }); + + it("binds extensions again when the SDK runtime replaces the active session", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("session-1"); + const replacement = fakeRuntime("session-2"); + let rebindSession: ((session: PiAgentSession) => Promise) | undefined; + fake.runtime.setRebindSession = (callback) => { rebindSession = callback; }; + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + Object.defineProperty(fake.runtime, "session", { configurable: true, value: replacement.session }); + await rebindSession?.(replacement.session); + + expect(fake.calls.bindExtensions).toHaveLength(1); + expect(replacement.calls.bindExtensions).toHaveLength(1); + expect(service.activeCount()).toBe(1); + expect(await service.status("session-2")).toMatchObject({ sessionId: "session-2" }); + + await service.dispose(); + }); + + it("publishes extension errors reported while binding session extensions", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("extension-session", { + bindExtensions: (bindings) => { + bindings.onError?.({ extensionPath: "pi-mcp-adapter", event: "session_start", error: "MCP failed" }); + return Promise.resolve(); + }, + }); + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + expect(hub.sessionEvents).toContainEqual({ + sessionId: "extension-session", + event: { type: "session.error", message: "pi-mcp-adapter: MCP failed" }, + }); + const extensionErrorActivity = hub.globalEvents.find((event) => event.type === "activity.update" && event.activity.sessionId === "extension-session"); + expect(extensionErrorActivity).toMatchObject({ + type: "activity.update", + activity: { sessionId: "extension-session", phase: "error", label: "extension error", detail: "pi-mcp-adapter: MCP failed" }, + }); + + await service.dispose(); + }); + + it("clears stale active activity once a previously active session becomes idle", async () => { + vi.useFakeTimers(); + let service: PiSessionService | undefined; + try { + const hub = new CapturingSessionEventHub(); + let listener: ((event: unknown) => void) | undefined; + const fake = fakeRuntime("idle-session", { + isStreaming: true, + subscribe: (next) => { + listener = next; + return () => undefined; + }, + }); + service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("idle-session")]), + heartbeatIntervalMs: 1_000, + }); + + await service.status(sessionRef("idle-session")); + hub.globalEvents.length = 0; + listener?.({ type: "agent_start" }); + + const activityPhases = () => hub.globalEvents + .filter((event) => event.type === "activity.update") + .map((event) => event.activity.phase); + expect(activityPhases()).toEqual(["active"]); + + fake.session.isStreaming = false; + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(1_000); + + expect(activityPhases()).toEqual(["active", "idle"]); + } finally { + await service?.dispose(); + vi.useRealTimers(); + } + }); + + it("publishes idle activity for SDK completion events", async () => { + const hub = new CapturingSessionEventHub(); + let listener: ((event: unknown) => void) | undefined; + const fake = fakeRuntime("completion-session", { + subscribe: (next) => { + listener = next; + return () => undefined; + }, + }); + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("completion-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("completion-session")); + hub.globalEvents.length = 0; + listener?.({ type: "tool_execution_end", toolName: "read", isError: false }); + + expect(hub.globalEvents.filter((event) => event.type === "activity.update")).toMatchObject([ + { activity: { sessionId: "completion-session", phase: "idle", label: "tool complete", detail: "read" } }, + ]); + + await service.dispose(); + }); + + it("uses injected archive and session-manager gateways for listing", async () => { + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: { + list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]), + get: () => Promise.resolve(undefined), + archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }), + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([ + { ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" }, + { ...sessionRecord("archived"), messageCount: 2, firstMessage: "bye", allMessagesText: "bye" }, + ]), + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + const sessions = await service.list("/workspace"); + expect(sessions).toHaveLength(2); + expect(sessions[0]).toMatchObject({ id: "active", persisted: true }); + expect(sessions[0]?.archived).toBeUndefined(); + expect(sessions[1]).toMatchObject({ id: "archived", archived: true, archivedAt: "2026-01-01T00:00:00.000Z" }); + + await service.dispose(); + }); + + it("lists archived records that have been moved out of the active session directory", async () => { + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: { + list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]), + get: () => Promise.resolve(undefined), + archive: () => { throw new Error("archive should not be called for moved records"); }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([{ ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" }]), + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + const sessions = await service.list("/workspace"); + + expect(sessions).toHaveLength(2); + expect(sessions[0]).toMatchObject({ id: "active" }); + expect(sessions[0]?.archived).toBeUndefined(); + expect(sessions[1]).toMatchObject({ id: "archived", path: "/sessions/archived.jsonl", archived: true, archivedAt: "2026-01-02T00:00:00.000Z" }); + + await service.dispose(); + }); + + + it("runs /reload by refreshing the active runtime resources in place", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("runtime-reload-session"); + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.runCommand(sessionRef("runtime-reload-session"), "/reload")).resolves.toEqual({ + type: "done", + message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.", + }); + + expect(fake.calls.reload).toBe(1); + expect(fake.calls.abort).toBe(0); + expect(fake.calls.dispose).toBe(0); + expect(hub.globalEvents.some((event) => event.type === "activity.update" && event.activity.sessionId === "runtime-reload-session" && event.activity.label === "resources reloaded")).toBe(true); + expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "runtime-reload-session")).toBe(true); + + await service.dispose(); + }); + + it("reloads a session by closing the active runtime and re-opening it from disk", async () => { + const first = fakeRuntime("reload-session"); + const second = fakeRuntime("reload-session"); + const runtimes = [first.runtime, second.runtime]; + let createCalls = 0; + const createAgentRuntime: RuntimeCreator = async () => { + await Promise.resolve(); + const runtime = runtimes[createCalls]; + createCalls += 1; + if (runtime === undefined) throw new Error("unexpected runtime creation"); + return runtime; + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: sessionGateway([sessionRecord("reload-session")]), + heartbeatIntervalMs: 60_000, + }); + + // Open once so there is an active runtime to reload. + await service.status(sessionRef("reload-session")); + expect(createCalls).toBe(1); + + await expect(service.reload(sessionRef("reload-session"))).resolves.toBeUndefined(); + + // The original runtime was torn down and a fresh one opened from disk. + expect(first.calls.abort).toBe(1); + expect(first.calls.dispose).toBe(1); + expect(createCalls).toBe(2); + expect(service.activeCount()).toBe(1); + + await service.dispose(); + }); + + it("refuses to reload a session that has active work in progress", async () => { + const fake = fakeRuntime("busy-session", { isStreaming: true }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("busy-session")]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.reload(sessionRef("busy-session"))).rejects.toThrow("Stop current session activity before reloading"); + expect(fake.calls.abort).toBe(0); + expect(fake.calls.dispose).toBe(0); + + await service.dispose(); + }); + + it("refuses to reload an archived session", async () => { + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: { + list: () => Promise.resolve([]), + get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) + ? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" } + : undefined), + archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }), + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(true), + }, + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.reload(sessionRef("archived"))).rejects.toThrow("Archived sessions are read-only"); + + await service.dispose(); + }); + + it("reconciles workspace activity when listing only archived sessions", async () => { + const reconciliations: { cwd: string; sessionIds: string[] }[] = []; + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: { + list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]), + get: () => Promise.resolve(undefined), + archive: () => { throw new Error("archive should not be called for moved records"); }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([]), + open: () => fakeSessionManager(), + }, + workspaceActivity: { + applySessionStatus: () => undefined, + applySessionActivity: () => undefined, + removeSession: () => undefined, + reconcileSessionActivity: (cwd, sessionIds) => { reconciliations.push({ cwd, sessionIds: [...sessionIds] }); }, + }, + heartbeatIntervalMs: 60_000, + }); + + const sessions = await service.list("/workspace"); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ id: "archived", archived: true }); + expect(reconciliations).toEqual([{ cwd: "/workspace", sessionIds: [] }]); + + await service.dispose(); + }); +}); diff --git a/src/server/sessions/piSessionService.promptQueue.test.ts b/src/server/sessions/piSessionService.promptQueue.test.ts new file mode 100644 index 0000000..ecad5cc --- /dev/null +++ b/src/server/sessions/piSessionService.promptQueue.test.ts @@ -0,0 +1,299 @@ +import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai"; +import type { StreamFn } from "@earendil-works/pi-agent-core"; +import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it, vi } from "vitest"; +import { PiSessionService } from "./piSessionService.js"; +import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; + +describe("PiSessionService prompt, queue, and auth warnings", () => { + it("sends prompts to an injected runtime without touching the SDK runtime", async () => { + const fake = fakeRuntime("prompt-session"); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("prompt-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt(sessionRef("prompt-session"), "Build the thing"); + + expect(fake.calls.prompt).toEqual([{ text: "Build the thing", options: undefined }]); + await service.dispose(); + }); + + it("echoes the user message for direct prompts but not command-forwarded ones", async () => { + const fake = fakeRuntime("echo-session", { + resourceLoader: { getSkills: () => ({ skills: [{ name: "skill-creator" }] }) }, + }); + const hub = new CapturingSessionEventHub(); + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("echo-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt(sessionRef("echo-session"), "Build the thing"); + expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1); + + // The client optimistically renders command-forwarded prompts (e.g. /skill:*), + // so the server must not publish a second copy via message.append. + await service.runCommand(sessionRef("echo-session"), "/skill:skill-creator"); + expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1); + expect(fake.calls.prompt).toEqual([ + { text: "Build the thing", options: undefined }, + { text: "/skill:skill-creator", options: undefined }, + ]); + + await service.dispose(); + }); + + it("rejects malformed prompt text before opening the runtime", async () => { + const fake = fakeRuntime("prompt-session"); + let createCalls = 0; + const createAgentRuntime: RuntimeCreator = async () => { + createCalls += 1; + await Promise.resolve(); + return fake.runtime; + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: sessionGateway([sessionRecord("prompt-session")]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.prompt("prompt-session", undefined)).rejects.toThrow("Prompt text is required"); + + expect(createCalls).toBe(0); + expect(fake.calls.prompt).toEqual([]); + await service.dispose(); + }); + + it("generates a session name for the first prompt via the session's agent.streamFn", async () => { + const model = testModel(); + const streamCalls: unknown[] = []; + const streamFn: StreamFn = (streamModel, context, options) => { + streamCalls.push({ streamModel, context, options }); + const stream = createAssistantMessageEventStream(); + const message: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "Fix login bug" }], + api: "anthropic-messages", + provider: "anthropic", + model: model.id, + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "stop", + timestamp: Date.now(), + }; + stream.push({ type: "done", reason: "stop", message }); + stream.end(message); + return stream; + }; + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("name-session", { model, agent: { streamFn } }); + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("name-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt(sessionRef("name-session"), "Please fix the login bug"); + await vi.waitFor(() => { expect(fake.session.sessionName).toBe("Fix login bug"); }); + + expect(streamCalls).toHaveLength(1); + expect(hub.sessionEvents.some(({ event }) => event.type === "session.name" && event.name === "Fix login bug")).toBe(true); + await service.dispose(); + }); + + it("includes queued message details in session status", async () => { + const fake = fakeRuntime("status-session", { + messages: [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }], + pendingMessageCount: 2, + getSteeringMessages: () => ["adjust this turn"], + getFollowUpMessages: () => ["then do this"], + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("status-session")]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.status(sessionRef("status-session"))).resolves.toMatchObject({ + pendingMessageCount: 2, + queuedMessages: [{ kind: "steer", text: "adjust this turn" }, { kind: "followUp", text: "then do this" }], + messageCount: 2, + }); + await service.dispose(); + }); + + it("does not enqueue duplicate queued message text", async () => { + const fake = fakeRuntime("dedupe-session", { + isStreaming: true, + pendingMessageCount: 1, + getFollowUpMessages: () => ["already queued"], + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("dedupe-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt(sessionRef("dedupe-session"), "already queued", "followUp"); + + expect(fake.calls.prompt).toEqual([]); + await service.dispose(); + }); + + it("does not append queued prompts to the transcript before delivery", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("queued-session", { isStreaming: true }); + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("queued-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt(sessionRef("queued-session"), "Wait for the current turn", "followUp"); + + expect(fake.calls.prompt).toEqual([{ text: "Wait for the current turn", options: { streamingBehavior: "followUp" } }]); + expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false); + await service.dispose(); + }); + + it("holds prompts sent during compaction until compaction finishes", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("compacting-session", { isCompacting: true }); + let resolveFirstPrompt: (() => void) | undefined; + fake.session.prompt = (text: string, options?: { streamingBehavior?: "steer" | "followUp" }) => { + fake.calls.prompt.push({ text, options }); + if (options === undefined) { + fake.session.isStreaming = true; + return new Promise((resolve) => { resolveFirstPrompt = resolve; }); + } + return Promise.resolve(); + }; + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("compacting-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt(sessionRef("compacting-session"), "Start task 1", "followUp"); + await service.prompt(sessionRef("compacting-session"), "Then task 2", "followUp"); + + expect(fake.calls.prompt).toEqual([]); + expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false); + await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({ + pendingMessageCount: 2, + queuedMessages: [{ kind: "followUp", text: "Start task 1" }, { kind: "followUp", text: "Then task 2" }], + }); + + fake.session.isCompacting = false; + fake.emit({ type: "compaction_end" }); + await new Promise((resolve) => setTimeout(resolve, 5)); + + expect(fake.calls.prompt).toEqual([{ text: "Start task 1", options: undefined }]); + expect(hub.sessionEvents.some(({ event }) => event.type === "message.append" && JSON.stringify(event.message).includes("Start task 1"))).toBe(true); + await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({ + pendingMessageCount: 1, + queuedMessages: [{ kind: "followUp", text: "Then task 2" }], + }); + + fake.emit({ type: "agent_start" }); + await new Promise((resolve) => setTimeout(resolve, 5)); + + expect(fake.calls.prompt).toEqual([ + { text: "Start task 1", options: undefined }, + { text: "Then task 2", options: { streamingBehavior: "followUp" } }, + ]); + await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({ + pendingMessageCount: 0, + queuedMessages: [], + }); + resolveFirstPrompt?.(); + await service.dispose(); + }); + + it("clears queued messages when aborting active work", async () => { + const fake = fakeRuntime("abort-session"); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("abort-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("abort-session")); + await service.abort(sessionRef("abort-session")); + + expect(fake.calls.clearQueue).toBe(1); + expect(fake.calls.abort).toBe(1); + await service.dispose(); + }); + + it("clears prompts queued during compaction when aborting active work", async () => { + const fake = fakeRuntime("abort-compaction-session", { isCompacting: true }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt(sessionRef("abort-compaction-session"), "Do not deliver after abort", "followUp"); + await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 1 }); + await service.abort(sessionRef("abort-compaction-session")); + + expect(fake.calls.clearQueue).toBe(1); + expect(fake.calls.prompt).toEqual([]); + await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 0, queuedMessages: [] }); + await service.dispose(); + }); + + it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => { + const hub = new CapturingSessionEventHub(); + const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } }); + const modelRegistry = ModelRegistry.inMemory(authStorage); + const model = modelRegistry.find("anthropic", "claude-3-5-sonnet-20241022"); + if (model === undefined) throw new Error("Expected Anthropic model fixture"); + const fake = fakeRuntime("auth-session", { model, modelRegistry }); + + const service = new PiSessionService(hub, { + modelRegistry, + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("auth-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("auth-session")); + hub.sessionEvents.length = 0; + hub.globalEvents.length = 0; + + authStorage.logout("anthropic"); + service.applyAuthChange({ removedProviderId: "anthropic" }); + service.applyAuthChange({ removedProviderId: "anthropic" }); + + const warningCount = () => hub.sessionEvents.filter(({ event }) => event.type === "command.output" && event.level === "error" && event.message.includes("anthropic/claude-3-5-sonnet-20241022")).length; + expect(warningCount()).toBe(1); + expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true); + + authStorage.set("anthropic", { type: "api_key", key: "sk-new" }); + service.applyAuthChange(); + authStorage.logout("anthropic"); + service.applyAuthChange({ removedProviderId: "anthropic" }); + expect(warningCount()).toBe(2); + + await service.dispose(); + }); + + it("clears queued messages when stopping a session runtime", async () => { + const fake = fakeRuntime("stop-session"); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("stop-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("stop-session")); + service.stop(sessionRef("stop-session")); + + expect(fake.calls.clearQueue).toBe(1); + await service.dispose(); + }); +}); diff --git a/src/server/sessions/piSessionService.spawnSession.test.ts b/src/server/sessions/piSessionService.spawnSession.test.ts new file mode 100644 index 0000000..8f3e51d --- /dev/null +++ b/src/server/sessions/piSessionService.spawnSession.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; +import type { SpawnTargetDecision } from "./spawnTargetResolver.js"; +import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; + +describe("PiSessionService", () => { + describe("spawnSession", () => { + function spawnService(decision: SpawnTargetDecision) { + const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" }); + const log: { details: Record; message: string }[] = []; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([]), + spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, + logger: { info: (details, message) => { log.push({ details, message }); } }, + heartbeatIntervalMs: 60_000, + }); + return { fake, service, log }; + } + + it("starts a session at the resolved target, delivers the prompt, and logs the spawn", async () => { + const { fake, service, log } = spawnService({ allowed: true, cwd: "/workspace-feature" }); + + const result = await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue the plan", cwd: "/workspace-feature" }); + + expect(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature" }); + expect(fake.calls.prompt).toEqual([{ text: "continue the plan", options: undefined }]); + expect(log).toEqual([{ details: { spawningCwd: "/workspace", sessionId: "spawned-1", cwd: "/workspace-feature", promptLength: 17 }, message: "spawn_session started a new session" }]); + await service.dispose(); + }); + + it("uses the dispatching session's model as the spawned session's initial model", async () => { + const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" }); + const model = testModel(); + let initialModel: PiAgentSession["model"]; + const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { + await Promise.resolve(); + initialModel = options.initialModel; + return fake.runtime; + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: sessionGateway([]), + spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, + heartbeatIntervalMs: 60_000, + }); + + await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue", cwd: "/workspace-feature", model }); + + expect(initialModel).toBe(model); + await service.dispose(); + }); + + it("rejects an out-of-project target without starting a session", async () => { + const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] }); + + await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: "/elsewhere" })) + .rejects.toThrow("cwd must be a workspace of this project. Allowed: /workspace"); + expect(fake.calls.prompt).toEqual([]); + expect(service.activeCount()).toBe(0); + await service.dispose(); + }); + + it("rejects when the spawning session is not in a registered project", async () => { + const { service } = spawnService({ allowed: false, reason: "not-registered" }); + + await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined })) + .rejects.toThrow("Spawning session is not in a registered project"); + await service.dispose(); + }); + + it("is disabled when no spawn target resolver is configured", async () => { + const fake = fakeRuntime("spawned-x"); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined })) + .rejects.toThrow("Spawning sessions is disabled"); + await service.dispose(); + }); + }); +}); diff --git a/src/server/sessions/piSessionService.spawnSubsession.test.ts b/src/server/sessions/piSessionService.spawnSubsession.test.ts new file mode 100644 index 0000000..b695529 --- /dev/null +++ b/src/server/sessions/piSessionService.spawnSubsession.test.ts @@ -0,0 +1,844 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; +import type { SpawnTargetDecision } from "./spawnTargetResolver.js"; +import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; + +describe("PiSessionService", () => { + describe("spawnSubsession", () => { + function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) { + const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" }); + const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") }); + const created = [parent.runtime, child.runtime]; + let index = 0; + const createAgentRuntime: RuntimeCreator = async () => { + await Promise.resolve(); + const runtime = created[Math.min(index, created.length - 1)] ?? child.runtime; + index += 1; + return runtime; + }; + const archived = new Map(); + const archiveStore = { + list: () => Promise.resolve([...archived.values()]), + get: (sessionId: string) => Promise.resolve(archived.get(sessionId)), + archive: (input: { sessionId: string; cwd: string }) => { + const record = { sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-01T00:00:00.000Z" }; + archived.set(input.sessionId, record); + return Promise.resolve(record); + }, + restore: (sessionId: string) => { archived.delete(sessionId); return Promise.resolve(); }, + isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)), + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: sessionGateway([]), + archiveStore, + spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, + heartbeatIntervalMs, + }); + return { parent, child, service }; + } + + it("records the parent, delivers the prompt, and lists the tracked child", async () => { + const { child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + await service.start("/workspace"); // bring the parent online so it can be notified + + const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" }); + + expect(result).toEqual({ sessionId: "child-1", cwd: "/workspace-feature" }); + expect(child.calls.prompt).toEqual([{ text: "do the slice", options: undefined }]); + await expect(service.listSubsessions("parent-1")).resolves.toEqual([ + { sessionId: "child-1", cwd: "/workspace-feature", status: "idle" }, + ]); + await service.dispose(); + }); + + it("uses the parent session's model as the tracked child's initial model", async () => { + const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" }); + const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") }); + const model = testModel(); + const initialModels: PiAgentSession["model"][] = []; + const runtimes = [parent.runtime, child.runtime]; + let index = 0; + const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { + await Promise.resolve(); + initialModels.push(options.initialModel); + const runtime = runtimes[index] ?? child.runtime; + index += 1; + return runtime; + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: sessionGateway([]), + archiveStore: emptyArchiveStore(), + spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model }); + + expect(initialModels).toEqual([undefined, model]); + await service.dispose(); + }); + + it("persists tracked child links in the parent and child sessions", async () => { + const parentPersisted: { customType: string; data?: unknown }[] = []; + const childPersisted: { customType: string; data?: unknown }[] = []; + const parent = fakeRuntime("parent-1", { + sessionFile: "/tmp/parent-1.jsonl", + sessionManager: fakeSessionManager("/workspace", { + appendCustomEntry: (customType, data) => { + parentPersisted.push({ customType, data }); + return "parent-entry-1"; + }, + }), + }); + const child = fakeRuntime("child-1", { + sessionFile: "/tmp/child-1.jsonl", + sessionManager: fakeSessionManager("/workspace-feature", { + appendCustomEntry: (customType, data) => { + childPersisted.push({ customType, data }); + return "child-entry-1"; + }, + }), + }); + const runtimes = [parent.runtime, child.runtime]; + let index = 0; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? child.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: sessionGateway([]), + archiveStore: emptyArchiveStore(), + spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" }); + + expect(parentPersisted).toEqual([ + { + customType: "pi-web.subsession.link", + data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/tmp/child-1.jsonl", cwd: "/workspace-feature" }, + }, + ]); + expect(childPersisted).toEqual([ + { + customType: "pi-web.subsession.spawned", + data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" }, + }, + ]); + await service.dispose(); + }); + + it("hydrates persisted child links after a service restart so the parent can inspect them", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-")); + const parentFile = join(tempDir, "parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getBranch: () => [{ type: "message", message: { role: "assistant", content: "finished" } }], + }); + const parent = fakeRuntime("parent-1", { + sessionFile: parentFile, + sessionManager: fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }), + }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const runtimes = [parent.runtime, child.runtime]; + let index = 0; + const open = vi.fn(() => childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? child.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.checkSubsession("parent-1", "child-1")).resolves.toEqual({ + sessionId: "child-1", + cwd: "/workspace-feature", + status: "idle", + finalText: "finished", + messageCount: 1, + }); + expect(open).toHaveBeenCalledWith(childFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("ignores stale persisted child links when the child no longer records the parent", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-")); + const parentFile = join(tempDir, "parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8"); + + try { + const parent = fakeRuntime("parent-1", { + sessionFile: parentFile, + sessionManager: fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(parent.runtime), + sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not hydrate persisted links when the exact child file is unavailable", async () => { + const parentFile = "/sessions/parent-1.jsonl"; + const parent = fakeRuntime("parent-1", { + sessionFile: parentFile, + sessionManager: fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }], + }), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(parent.runtime), + sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); + await service.dispose(); + }); + + it("does not hydrate parent links without a child file", async () => { + const parentFile = "/sessions/parent-1.jsonl"; + const parent = fakeRuntime("parent-1", { + sessionFile: parentFile, + sessionManager: fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child", cwd: "/workspace-feature" } }], + }), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(parent.runtime), + sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); + await service.dispose(); + }); + + it("does not invent subsession links from existing child session headers", async () => { + const parentFile = "/sessions/parent-1.jsonl"; + const childRecord = { ...sessionRecord("child-1", "/workspace-feature"), path: "/sessions/child-1.jsonl", parentSessionPath: parentFile }; + const parent = fakeRuntime("parent-1", { + sessionFile: parentFile, + sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(parent.runtime), + sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); + await service.dispose(); + }); + + it("does not hydrate copied parent links when the opened parent has a different id", async () => { + const forkedParent = fakeRuntime("parent-fork-1", { + sessionFile: "/sessions/parent-fork-1.jsonl", + sessionManager: fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }], + }), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(forkedParent.runtime), + sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-fork-1")).resolves.toEqual([]); + await service.dispose(); + }); + + it("relinks a spawned child when the child session is opened after restart", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-open-child-")); + const parentFile = join(tempDir, "parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: parentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const runtimes = [child.runtime, parent.runtime]; + let index = 0; + const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? parent.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => childManager, + list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(parent.calls.sendCustomMessage).toHaveLength(1); + expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); + expect(open).toHaveBeenCalledWith(parentFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("notifies the validated parent file instead of an active prefix-matched parent id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-prefix-parent-")); + const parentFile = join(tempDir, "parent.jsonl"); + const forkParentFile = join(tempDir, "parent-fork.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(forkParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1-fork", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: parentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }); + const forkManager = fakeSessionManager("/workspace"); + const fork = fakeRuntime("parent-1-fork", { sessionFile: forkParentFile, sessionManager: forkManager }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const runtimes = [fork.runtime, child.runtime, parent.runtime]; + let index = 0; + const open = vi.fn((path: string) => { + if (path === parentFile) return parentManager; + if (path === forkParentFile) return forkManager; + return childManager; + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? parent.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => forkManager, + list: (cwd: string) => Promise.resolve(cwd === "/workspace" + ? [{ ...sessionRecord("parent-1-fork", "/workspace"), path: forkParentFile }] + : [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("parent-1-fork", "/workspace")); + await service.status(sessionRef("child-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(fork.calls.sendCustomMessage).toHaveLength(0); + expect(parent.calls.sendCustomMessage).toHaveLength(1); + expect(open).toHaveBeenCalledWith(parentFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not relink a copied child with the original session id unless the parent link names the current child file", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-copied-child-")); + const parentFile = join(tempDir, "parent.jsonl"); + const originalChildFile = join(tempDir, "original-child.jsonl"); + const copiedChildFile = join(tempDir, "copied-child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: parentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }], + }); + const child = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: childManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const runtimes = [child.runtime, parent.runtime]; + let index = 0; + const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? parent.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => childManager, + list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(parent.calls.sendCustomMessage).toHaveLength(0); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("uses the verified child file instead of an active copied child with the same id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-child-")); + const parentFile = join(tempDir, "parent.jsonl"); + const originalChildFile = join(tempDir, "original-child.jsonl"); + const copiedChildFile = join(tempDir, "copied-child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const copiedManager = fakeSessionManager("/workspace-feature", { + getBranch: () => [{ type: "message", message: { role: "assistant", content: "copied child result" } }], + }); + const originalManager = fakeSessionManager("/workspace-feature", { + getBranch: () => [{ type: "message", message: { role: "assistant", content: "original child result" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }], + }); + const copiedChild = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: copiedManager, isStreaming: true }); + const originalChild = fakeRuntime("child-1", { sessionFile: originalChildFile, sessionManager: originalManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => { + if (options.sessionManager === copiedManager) return Promise.resolve(copiedChild.runtime); + if (options.sessionManager === originalManager) return Promise.resolve(originalChild.runtime); + if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime); + throw new Error("unexpected session manager"); + }; + const open = vi.fn((path: string) => { + if (path === copiedChildFile) return copiedManager; + if (path === originalChildFile) return originalManager; + if (path === parentFile) return parentManager; + throw new Error(`unexpected open path ${path}`); + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: { + create: () => parentManager, + list: (cwd: string) => Promise.resolve(cwd === "/workspace-feature" ? [{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }] : []), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-1", parentFile)).resolves.toEqual([ + { sessionId: "child-1", cwd: "/workspace-feature", status: "idle" }, + ]); + + copiedChild.session.isStreaming = true; + copiedChild.emit({ type: "agent_start" }); + copiedChild.session.isStreaming = false; + copiedChild.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(parent.calls.sendCustomMessage).toHaveLength(0); + + await expect(service.checkSubsession("parent-1", "child-1", parentFile)).resolves.toMatchObject({ + sessionId: "child-1", + cwd: "/workspace-feature", + status: "idle", + finalText: "original child result", + messageCount: 1, + }); + const read = await service.readSubsession("parent-1", "child-1", { roles: ["assistant"] }, parentFile); + expect(read.entries[0]?.parts[0]).toMatchObject({ kind: "text", text: "original child result" }); + expect(open).toHaveBeenCalledWith(originalChildFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("uses the verified parent file instead of an active copied parent with the same id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-parent-")); + const parentFile = join(tempDir, "parent.jsonl"); + const copiedParentFile = join(tempDir, "copied-parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(copiedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + getBranch: () => [{ type: "message", message: { role: "assistant", content: "child result" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }); + const copiedParentManager = fakeSessionManager("/workspace", { getEntries: () => [] }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const copiedParent = fakeRuntime("parent-1", { sessionFile: copiedParentFile, sessionManager: copiedParentManager }); + const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => { + if (options.sessionManager === childManager) return Promise.resolve(child.runtime); + if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime); + if (options.sessionManager === copiedParentManager) return Promise.resolve(copiedParent.runtime); + throw new Error("unexpected session manager"); + }; + const open = vi.fn((path: string) => { + if (path === childFile) return childManager; + if (path === parentFile) return parentManager; + if (path === copiedParentFile) return copiedParentManager; + throw new Error(`unexpected open path ${path}`); + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: { + create: () => copiedParentManager, + list: (cwd: string) => Promise.resolve(cwd === "/workspace" + ? [{ ...sessionRecord("parent-1", "/workspace"), path: copiedParentFile }] + : [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + await service.status(sessionRef("parent-1", "/workspace")); + + await expect(service.listSubsessions("parent-1", copiedParentFile)).resolves.toEqual([]); + await expect(service.checkSubsession("parent-1", "child-1", copiedParentFile)).rejects.toThrow("not one of your subsessions"); + await expect(service.readSubsession("parent-1", "child-1", {}, copiedParentFile)).rejects.toThrow("not one of your subsessions"); + + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(copiedParent.calls.sendCustomMessage).toHaveLength(0); + expect(parent.calls.sendCustomMessage).toHaveLength(1); + expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); + expect(open).toHaveBeenCalledWith(parentFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not relink a child marker when the current child file header no longer records the parent", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-child-header-")); + const parentFile = join(tempDir, "parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: parentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const runtimes = [child.runtime, parent.runtime]; + let index = 0; + const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? parent.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => childManager, + list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: { + ...emptyArchiveStore(), + get: (sessionId) => Promise.resolve(sessionId === "child-1" ? { sessionId: "child-1", cwd: "/workspace-feature", archivedAt: "2026-01-01T00:00:00.000Z", parentSessionPath: parentFile } : undefined), + }, + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(parent.calls.sendCustomMessage).toHaveLength(0); + expect(open).not.toHaveBeenCalledWith(parentFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not relink a child marker when the child header points at a different parent id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-wrong-parent-")); + const mismatchedParentFile = join(tempDir, "other-parent.jsonl"); + const actualParentFile = join(tempDir, "parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(mismatchedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "other-parent", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: mismatchedParentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: mismatchedParentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const parent = fakeRuntime("parent-1", { sessionFile: actualParentFile, sessionManager: fakeSessionManager("/workspace") }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const runtimes = [child.runtime, parent.runtime]; + let index = 0; + const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? parent.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => childManager, + list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: mismatchedParentFile }]), + listAll: () => Promise.resolve([{ ...sessionRecord("parent-1", "/workspace"), path: actualParentFile }]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(parent.calls.sendCustomMessage).toHaveLength(0); + expect(open).not.toHaveBeenCalledWith(actualParentFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not relink copied child markers when the opened child has a different id", async () => { + const parentFile = "/sessions/parent-1.jsonl"; + const childFile = "/sessions/child-fork-1.jsonl"; + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: parentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager }); + const open = vi.fn(() => childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(child.runtime), + sessionManager: { + create: () => childManager, + list: () => Promise.resolve([{ ...sessionRecord("child-fork-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-fork-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(open).not.toHaveBeenCalledWith(parentFile); + await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); + await service.dispose(); + }); + + it("notifies the parent once when the tracked child stops working", async () => { + const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); + parent.calls.prompt.length = 0; // ignore the spawn prompt to the child; focus on the parent notification + + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); // arm the notification + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); // fire once + child.emit({ type: "turn_end" }); // must not re-notify + await new Promise((resolve) => setTimeout(resolve, 20)); // the parent notification is delivered via the async custom-message path + + expect(parent.calls.sendCustomMessage).toHaveLength(1); + expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); + expect(parent.calls.sendCustomMessage[0]?.message.customType).toBe("subsession.completion"); + expect(parent.calls.sendCustomMessage[0]?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" }); + expect(parent.calls.prompt).toHaveLength(0); // not a user-authored message + await service.dispose(); + }); + + it("notifies via the heartbeat when the child settles without a further event", async () => { + const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }, 10); + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); + parent.calls.prompt.length = 0; + + // The child works, then settles silently: agent_end arrives while it still + // reports active work, so the event-driven latch does not fire here. + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.emit({ type: "agent_end" }); + expect(parent.calls.sendCustomMessage).toHaveLength(0); + + // Once the session settles, the periodic heartbeat re-check notifies. + child.session.isStreaming = false; + await new Promise((resolve) => setTimeout(resolve, 40)); + + expect(parent.calls.sendCustomMessage).toHaveLength(1); + expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); + await service.dispose(); + }); + + it("does not notify the parent when a tracked child is archived", async () => { + const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); + // Arm the notification, as a real working child would. + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + parent.calls.sendCustomMessage.length = 0; + + await service.archive("child-1"); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(parent.calls.sendCustomMessage).toHaveLength(0); + await service.dispose(); + }); + + it("reports a missing tracked child file as unknown in the subsession list", async () => { + const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); + + await service.archive("child-1"); + + await expect(service.listSubsessions("parent-1")).resolves.toEqual([ + { sessionId: "child-1", cwd: "/workspace-feature", status: "unknown" }, + ]); + await service.dispose(); + }); + + it("check_subsession and read_subsession refuse sessions that are not the caller's children", async () => { + const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); + + await expect(service.checkSubsession("someone-else", "child-1")).rejects.toThrow("not one of your subsessions"); + await expect(service.readSubsession("someone-else", "child-1", {})).rejects.toThrow("not one of your subsessions"); + await service.dispose(); + }); + + it("is disabled when no spawn target resolver is configured", async () => { + const fake = fakeRuntime("nope"); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + await expect(service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "p", parentSessionFile: undefined, prompt: "go", cwd: undefined })) + .rejects.toThrow("Spawning sessions is disabled"); + await service.dispose(); + }); + }); +}); diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts deleted file mode 100644 index f9b8f93..0000000 --- a/src/server/sessions/piSessionService.test.ts +++ /dev/null @@ -1,2105 +0,0 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai"; -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; -import { describe, expect, it, vi } from "vitest"; -import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js"; -import { SessionEventHub } from "../realtime/sessionEventHub.js"; -import { PiSessionService, type PiAgentSession, type PiSessionManager, type PiSessionRuntime, type PiSessionServiceDependencies } from "./piSessionService.js"; -import type { SpawnTargetDecision } from "./spawnTargetResolver.js"; - -class CapturingSessionEventHub extends SessionEventHub { - readonly sessionEvents: { sessionId: string; event: SessionUiEvent }[] = []; - readonly globalEvents: GlobalSessionEvent[] = []; - - override publish(sessionId: string, event: SessionUiEvent): void { - this.sessionEvents.push({ sessionId, event }); - } - - override publishGlobal(event: GlobalSessionEvent): void { - this.globalEvents.push(event); - } -} - -type SessionGateway = NonNullable; -type RuntimeCreator = NonNullable; - -interface TestSession extends PiAgentSession { - sessionName: string | undefined; - model: PiAgentSession["model"]; - isStreaming: boolean; - isCompacting: boolean; - isBashRunning: boolean; - pendingMessageCount: number; - getSteeringMessages: () => readonly string[]; - getFollowUpMessages: () => readonly string[]; -} - -function fakeSessionManager(cwd = "/workspace", patch: Partial = {}): PiSessionManager { - return { - getCwd: () => cwd, - getBranch: () => [], - getLeafId: () => "leaf-1", - ...patch, - }; -} - -function sessionRecord(id: string, cwd = "/workspace") { - return { id, path: `/sessions/${id}.jsonl`, cwd, created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }; -} - -function sessionRef(id: string, cwd = "/workspace") { - return { id, cwd }; -} - -function testModel(): NonNullable { - const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find("anthropic", "claude-3-5-sonnet-20241022"); - if (model === undefined) throw new Error("test model not found"); - return model; -} - -function fakeRuntime(sessionId = "session-1", patch: Partial = {}) { - const promptCalls: { text: string; options: unknown }[] = []; - const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = []; - const bindExtensionCalls: unknown[] = []; - const listeners: ((event: unknown) => void)[] = []; - const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls }; - const session: TestSession = { - sessionId, - sessionFile: `/tmp/${sessionId}.jsonl`, - messages: [], - sessionName: undefined, - model: undefined, - thinkingLevel: "off", - isStreaming: false, - isCompacting: false, - isBashRunning: false, - pendingMessageCount: 0, - sessionManager: fakeSessionManager(), - modelRegistry: ModelRegistry.create(AuthStorage.inMemory()), - scopedModels: [], - extensionRunner: { getRegisteredCommands: () => [] }, - promptTemplates: [], - resourceLoader: { getSkills: () => ({ skills: [] }) }, - subscribe: (listener: (event: unknown) => void) => { - listeners.push(listener); - return () => { - const index = listeners.indexOf(listener); - if (index !== -1) listeners.splice(index, 1); - }; - }, - bindExtensions: (bindings: unknown) => { - calls.bindExtensions.push(bindings); - return Promise.resolve(); - }, - getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }), - getContextUsage: () => undefined, - reload: () => { - calls.reload += 1; - return Promise.resolve(); - }, - prompt: (text: string, options: unknown) => { - calls.prompt.push({ text, options }); - return Promise.resolve(); - }, - sendCustomMessage: (message: { customType: string; content: string; display: boolean; details?: unknown }, options: unknown) => { - calls.sendCustomMessage.push({ message, options }); - return Promise.resolve(); - }, - executeBash: () => Promise.resolve({ output: "", exitCode: 0, cancelled: false, truncated: false }), - abort: () => { - calls.abort += 1; - return Promise.resolve(); - }, - clearQueue: () => { - calls.clearQueue += 1; - return { steering: [], followUp: [] }; - }, - getSteeringMessages: () => [], - getFollowUpMessages: () => [], - setModel: () => Promise.resolve(), - cycleModel: () => Promise.resolve(undefined), - getAvailableThinkingLevels: () => [], - setThinkingLevel: () => undefined, - cycleThinkingLevel: () => undefined, - setSessionName: (name: string) => { session.sessionName = name; }, - compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }), - getUserMessagesForForking: () => [], - agent: { streamFn: () => { throw new Error("streamFn should not be called in this test"); } }, - ...patch, - }; - const runtime: PiSessionRuntime = { - cwd: session.sessionManager.getCwd(), - session, - setRebindSession: () => undefined, - fork: () => Promise.resolve({ cancelled: false }), - dispose: () => { - calls.dispose += 1; - return Promise.resolve(); - }, - }; - return { runtime, session, calls, emit: (event: unknown) => { for (const listener of [...listeners]) listener(event); } }; -} - -function runtimeCreator(runtime: PiSessionRuntime): RuntimeCreator { - return async () => { - await Promise.resolve(); - return runtime; - }; -} - -function sessionGateway(records: ReturnType[]): SessionGateway { - return { - create: () => fakeSessionManager(), - list: () => Promise.resolve(records), - open: () => fakeSessionManager(), - }; -} - -function emptyArchiveStore(): NonNullable { - return { - list: () => Promise.resolve([]), - get: () => Promise.resolve(undefined), - archive: () => Promise.reject(new Error("archive should not be called")), - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }; -} - -describe("PiSessionService", () => { - it("starts sessions through an injected runtime creator", async () => { - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime(); - let createCalls = 0; - const createAgentRuntime: RuntimeCreator = async () => { - createCalls += 1; - await Promise.resolve(); - return fake.runtime; - }; - const service = new PiSessionService(hub, { - createAgentRuntime, - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - - const session = await service.start("/workspace"); - - expect(createCalls).toBe(1); - expect(fake.calls.bindExtensions).toHaveLength(1); - expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 }); - expect(service.activeCount()).toBe(1); - expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true); - expect(hub.globalEvents.some((event) => event.type === "session.created" && event.session.id === "session-1" && event.session.cwd === "/workspace")).toBe(true); - - await service.dispose(); - expect(fake.calls.abort).toBe(1); - expect(fake.calls.dispose).toBe(1); - }); - - it("reports persistence from actual session-file existence for fresh active sessions", async () => { - const dir = await mkdtemp(join(tmpdir(), "pi-web-persisted-")); - const sessionFile = join(dir, "new-session.jsonl"); - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("new-session", { sessionFile }); - let service: PiSessionService | undefined; - try { - service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - - const session = await service.start("/workspace"); - const createdEvent = hub.globalEvents.find((event) => event.type === "session.created"); - - expect(session).toMatchObject({ id: "new-session", path: sessionFile, persisted: false }); - expect(createdEvent).toMatchObject({ type: "session.created", session: { id: "new-session", persisted: false } }); - await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: false }); - - await writeFile(sessionFile, '{"type":"session","id":"new-session"}\n', "utf8"); - - await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: true }); - } finally { - await service?.dispose(); - await rm(dir, { recursive: true, force: true }); - } - }); - - it("opens legacy id-only lookups from the default session store gateway", async () => { - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("legacy-session"); - const open = vi.fn(() => fakeSessionManager()); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([]), - listAll: () => Promise.resolve([sessionRecord("legacy-session")]), - open, - }, - heartbeatIntervalMs: 60_000, - }); - - await expect(service.status("legacy")).resolves.toMatchObject({ sessionId: "legacy-session" }); - expect(open).toHaveBeenCalledWith("/sessions/legacy-session.jsonl"); - - await service.dispose(); - }); - - it("binds extensions again when the SDK runtime replaces the active session", async () => { - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("session-1"); - const replacement = fakeRuntime("session-2"); - let rebindSession: ((session: PiAgentSession) => Promise) | undefined; - fake.runtime.setRebindSession = (callback) => { rebindSession = callback; }; - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - Object.defineProperty(fake.runtime, "session", { configurable: true, value: replacement.session }); - await rebindSession?.(replacement.session); - - expect(fake.calls.bindExtensions).toHaveLength(1); - expect(replacement.calls.bindExtensions).toHaveLength(1); - expect(service.activeCount()).toBe(1); - expect(await service.status("session-2")).toMatchObject({ sessionId: "session-2" }); - - await service.dispose(); - }); - - it("publishes extension errors reported while binding session extensions", async () => { - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("extension-session", { - bindExtensions: (bindings) => { - bindings.onError?.({ extensionPath: "pi-mcp-adapter", event: "session_start", error: "MCP failed" }); - return Promise.resolve(); - }, - }); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - expect(hub.sessionEvents).toContainEqual({ - sessionId: "extension-session", - event: { type: "session.error", message: "pi-mcp-adapter: MCP failed" }, - }); - const extensionErrorActivity = hub.globalEvents.find((event) => event.type === "activity.update" && event.activity.sessionId === "extension-session"); - expect(extensionErrorActivity).toMatchObject({ - type: "activity.update", - activity: { sessionId: "extension-session", phase: "error", label: "extension error", detail: "pi-mcp-adapter: MCP failed" }, - }); - - await service.dispose(); - }); - - it("clears stale active activity once a previously active session becomes idle", async () => { - vi.useFakeTimers(); - let service: PiSessionService | undefined; - try { - const hub = new CapturingSessionEventHub(); - let listener: ((event: unknown) => void) | undefined; - const fake = fakeRuntime("idle-session", { - isStreaming: true, - subscribe: (next) => { - listener = next; - return () => undefined; - }, - }); - service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("idle-session")]), - heartbeatIntervalMs: 1_000, - }); - - await service.status(sessionRef("idle-session")); - hub.globalEvents.length = 0; - listener?.({ type: "agent_start" }); - - const activityPhases = () => hub.globalEvents - .filter((event) => event.type === "activity.update") - .map((event) => event.activity.phase); - expect(activityPhases()).toEqual(["active"]); - - fake.session.isStreaming = false; - await vi.advanceTimersByTimeAsync(1_000); - await vi.advanceTimersByTimeAsync(1_000); - - expect(activityPhases()).toEqual(["active", "idle"]); - } finally { - await service?.dispose(); - vi.useRealTimers(); - } - }); - - it("publishes idle activity for SDK completion events", async () => { - const hub = new CapturingSessionEventHub(); - let listener: ((event: unknown) => void) | undefined; - const fake = fakeRuntime("completion-session", { - subscribe: (next) => { - listener = next; - return () => undefined; - }, - }); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("completion-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("completion-session")); - hub.globalEvents.length = 0; - listener?.({ type: "tool_execution_end", toolName: "read", isError: false }); - - expect(hub.globalEvents.filter((event) => event.type === "activity.update")).toMatchObject([ - { activity: { sessionId: "completion-session", phase: "idle", label: "tool complete", detail: "read" } }, - ]); - - await service.dispose(); - }); - - it("uses injected archive and session-manager gateways for listing", async () => { - const service = new PiSessionService(new CapturingSessionEventHub(), { - archiveStore: { - list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]), - get: () => Promise.resolve(undefined), - archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }), - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }, - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([ - { ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" }, - { ...sessionRecord("archived"), messageCount: 2, firstMessage: "bye", allMessagesText: "bye" }, - ]), - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - const sessions = await service.list("/workspace"); - expect(sessions).toHaveLength(2); - expect(sessions[0]).toMatchObject({ id: "active", persisted: true }); - expect(sessions[0]?.archived).toBeUndefined(); - expect(sessions[1]).toMatchObject({ id: "archived", archived: true, archivedAt: "2026-01-01T00:00:00.000Z" }); - - await service.dispose(); - }); - - it("lists archived records that have been moved out of the active session directory", async () => { - const service = new PiSessionService(new CapturingSessionEventHub(), { - archiveStore: { - list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]), - get: () => Promise.resolve(undefined), - archive: () => { throw new Error("archive should not be called for moved records"); }, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }, - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([{ ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" }]), - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - const sessions = await service.list("/workspace"); - - expect(sessions).toHaveLength(2); - expect(sessions[0]).toMatchObject({ id: "active" }); - expect(sessions[0]?.archived).toBeUndefined(); - expect(sessions[1]).toMatchObject({ id: "archived", path: "/sessions/archived.jsonl", archived: true, archivedAt: "2026-01-02T00:00:00.000Z" }); - - await service.dispose(); - }); - - it("archives a session subtree within the root workspace", async () => { - const archivedInputs: string[] = []; - const root = sessionRecord("root"); - const directChild = { ...sessionRecord("direct-child"), path: "/sessions/direct-child.jsonl", parentSessionPath: root.path }; - const archivedChild = { ...sessionRecord("archived-child"), path: "/sessions/archived-child.jsonl", parentSessionPath: root.path }; - const grandchild = { ...sessionRecord("grandchild"), path: "/sessions/grandchild.jsonl", parentSessionPath: archivedChild.path }; - const otherWorkspaceChild = { ...sessionRecord("other-child", "/other"), path: "/sessions/other-child.jsonl", parentSessionPath: root.path }; - const fake = fakeRuntime("root", { sessionFile: root.path }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - archiveStore: { - list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]), - get: () => Promise.resolve(undefined), - archive: (input) => { - archivedInputs.push(input.sessionId); - return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }); - }, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }, - sessionManager: { - create: () => fakeSessionManager(), - list: (cwd) => Promise.resolve(cwd === "/workspace" ? [root, directChild, archivedChild, grandchild] : [otherWorkspaceChild]), - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - await expect(service.archiveTree(sessionRef("root"))).resolves.toEqual({ - archived: true, - sessionIds: ["root", "direct-child", "grandchild"], - archivedCount: 3, - skippedAlreadyArchivedCount: 1, - }); - expect(archivedInputs).toEqual(["root", "direct-child", "grandchild"]); - - await service.dispose(); - }); - - it("permanently deletes archived sessions through the archive store", async () => { - const deletedSessionIds: string[] = []; - const service = new PiSessionService(new CapturingSessionEventHub(), { - archiveStore: { - list: () => Promise.resolve([]), - get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) - ? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" } - : undefined), - archive: () => { throw new Error("archive should not be called for records that already have archive files"); }, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - deleteArchived: (sessionId) => { - deletedSessionIds.push(sessionId); - return Promise.resolve(); - }, - }, - sessionManager: sessionGateway([sessionRecord("active")]), - heartbeatIntervalMs: 60_000, - }); - - await expect(service.deleteArchived("arch")).resolves.toBeUndefined(); - await expect(service.deleteArchived("active")).rejects.toThrow("Archived session not found"); - - expect(deletedSessionIds).toEqual(["archived"]); - await service.dispose(); - }); - - it("bulk archives inactive sessions by cwd without opening runtimes", async () => { - const recordsByCwd = new Map([ - ["/one", [sessionRecord("a", "/one"), sessionRecord("b", "/one")]], - ["/two", [sessionRecord("c", "/two")]], - ]); - const listCalls: string[] = []; - const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); }); - const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); - const service = new PiSessionService(new CapturingSessionEventHub(), { - archiveStore: { - list: () => Promise.resolve([]), - get: () => Promise.resolve(undefined), - archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), - archiveMany, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }, - sessionManager: { - create: () => fakeSessionManager(), - list: (cwd) => { - listCalls.push(cwd); - return Promise.resolve(recordsByCwd.get(cwd) ?? []); - }, - open, - }, - heartbeatIntervalMs: 60_000, - }); - - const result = await service.archiveMany([{ id: "a", cwd: "/one" }, { id: "b", cwd: "/one" }, { id: "c", cwd: "/two" }]); - - expect(result).toMatchObject({ archived: true, archivedSessionIds: ["a", "b", "c"], failures: [] }); - expect(listCalls).toEqual(["/one", "/two"]); - expect(open).not.toHaveBeenCalled(); - expect(archiveMany).toHaveBeenCalledTimes(1); - expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["a", "b", "c"]); - await service.dispose(); - }); - - it("bulk archive reports per-session failures without aborting other archives", async () => { - const busy = fakeRuntime("busy", { isStreaming: true }); - let createCalls = 0; - const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { - createCalls += 1; - return Promise.resolve(busy.runtime); - }, - archiveStore: { - list: () => Promise.resolve([]), - get: () => Promise.resolve(undefined), - archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), - archiveMany, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }, - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([sessionRecord("busy"), sessionRecord("ok")]), - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("busy")); - const result = await service.archiveMany([{ id: "busy", cwd: "/workspace" }, { id: "ok", cwd: "/workspace" }, { id: "missing", cwd: "/workspace" }]); - - expect(createCalls).toBe(1); - expect(busy.calls.abort).toBe(0); - expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["ok"]); - expect(result.archivedSessionIds).toEqual(["ok"]); - expect(result.failures).toEqual([ - { sessionId: "busy", error: "Stop current session activity before archiving" }, - { sessionId: "missing", error: "Session not found" }, - ]); - await service.dispose(); - }); - - it("bulk deletes only archived sessions and skips busy active archived runtimes", async () => { - const busyRecord = { sessionId: "busy-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/busy.jsonl" }; - const idleRecord = { sessionId: "idle-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/idle.jsonl" }; - const busy = fakeRuntime("busy-archived", { isStreaming: true }); - const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(busy.runtime), - archiveStore: { - list: () => Promise.resolve([busyRecord, idleRecord]), - get: (sessionId) => Promise.resolve(sessionId === "busy-archived" ? busyRecord : undefined), - archive: () => { throw new Error("archive should not be called for records that already have archive files"); }, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - deleteArchived: () => Promise.resolve(), - deleteArchivedMany, - }, - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([sessionRecord("unarchived")]), - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("busy-archived")); - const result = await service.deleteArchivedMany([{ id: "busy-archived", cwd: "/workspace" }, { id: "idle-archived", cwd: "/workspace" }, { id: "unarchived", cwd: "/workspace" }]); - - expect(busy.calls.abort).toBe(0); - expect(deleteArchivedMany).toHaveBeenCalledWith(["idle-archived"]); - expect(result.deletedSessionIds).toEqual(["idle-archived"]); - expect(result.failures).toEqual([ - { sessionId: "busy-archived", error: "Stop current session activity before deleting archived session" }, - { sessionId: "unarchived", error: "Archived session not found" }, - ]); - await service.dispose(); - }); - - it("bulk delete moves legacy archived records with one workspace scan before deleting", async () => { - const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` })))); - const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); - const listCalls: string[] = []; - const service = new PiSessionService(new CapturingSessionEventHub(), { - archiveStore: { - list: () => Promise.resolve([ - { sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }, - { sessionId: "legacy-b", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }, - { sessionId: "moved", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/moved.jsonl" }, - ]), - get: () => Promise.resolve(undefined), - archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), - archiveMany, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - deleteArchived: () => Promise.resolve(), - deleteArchivedMany, - }, - sessionManager: { - create: () => fakeSessionManager(), - list: (cwd) => { - listCalls.push(cwd); - return Promise.resolve([sessionRecord("legacy-a"), sessionRecord("legacy-b"), sessionRecord("unarchived")]); - }, - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - const result = await service.deleteArchivedMany([{ id: "legacy-a", cwd: "/workspace" }, { id: "legacy-b", cwd: "/workspace" }, { id: "moved", cwd: "/workspace" }]); - - expect(listCalls).toEqual(["/workspace"]); - expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]); - expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b", "moved"]); - expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b", "moved"]); - expect(result.failures).toEqual([]); - await service.dispose(); - }); - - it("previews session cleanup without mutating and executes a recomputed plan", async () => { - const archivedInputs: string[] = []; - const deletedSessionIds: string[] = []; - let listAllCalls = 0; - const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" }; - const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" }; - const service = new PiSessionService(new CapturingSessionEventHub(), { - now: () => new Date("2026-06-25T00:00:00.000Z"), - archiveStore: { - list: () => Promise.resolve([archived, otherArchived]), - get: () => Promise.resolve(undefined), - archive: () => Promise.reject(new Error("cleanup should use archiveMany")), - archiveMany: (inputs) => { - archivedInputs.push(...inputs.map((input) => input.sessionId)); - return Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }))); - }, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")), - deleteArchivedMany: (sessionIds) => { - deletedSessionIds.push(...sessionIds); - return Promise.resolve([...sessionIds]); - }, - }, - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([]), - listAll: () => { - listAllCalls += 1; - return Promise.resolve([ - listAllCalls === 1 ? sessionRecord("preview-only", "/old-project") : sessionRecord("execute-only", "/old-project"), - listAllCalls === 1 ? sessionRecord("preview-other", "/other-project") : sessionRecord("execute-other", "/other-project"), - ]); - }, - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - const preview = await service.cleanupPreview({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); - expect(preview.totals).toEqual({ archiveCount: 1, deleteCount: 1 }); - expect(preview.projects).toEqual([{ cwd: "/old-project", archiveCount: 1, deleteCount: 1 }]); - expect(archivedInputs).toEqual([]); - expect(deletedSessionIds).toEqual([]); - - const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); - expect(result.archivedSessionIds).toEqual(["execute-only"]); - expect(result.deletedSessionIds).toEqual(["archived-old"]); - expect(archivedInputs).toEqual(["execute-only"]); - expect(deletedSessionIds).toEqual(["archived-old"]); - - await service.dispose(); - }); - - it("moves legacy cleanup delete records with one workspace scan before batch deleting", async () => { - const listCalls: string[] = []; - const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` })))); - const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); - const service = new PiSessionService(new CapturingSessionEventHub(), { - now: () => new Date("2026-06-25T00:00:00.000Z"), - archiveStore: { - list: () => Promise.resolve([ - { sessionId: "legacy-a", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" }, - { sessionId: "legacy-b", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" }, - ]), - get: () => Promise.resolve(undefined), - archive: () => Promise.reject(new Error("cleanup should use archiveMany")), - archiveMany, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")), - deleteArchivedMany, - }, - sessionManager: { - create: () => fakeSessionManager(), - list: (cwd) => { - listCalls.push(cwd); - return Promise.resolve([sessionRecord("legacy-a", cwd), sessionRecord("legacy-b", cwd)]); - }, - listAll: () => Promise.resolve([]), - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - const result = await service.cleanup({ thresholds: { deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); - - expect(listCalls).toEqual(["/old-project"]); - expect(archiveMany).toHaveBeenCalledTimes(1); - expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]); - expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b"]); - expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b"]); - - await service.dispose(); - }); - - it("skips busy active sessions during cleanup execution", async () => { - const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" }); - const archivedInputs: string[] = []; - const service = new PiSessionService(new CapturingSessionEventHub(), { - now: () => new Date("2026-06-25T00:00:00.000Z"), - createAgentRuntime: runtimeCreator(fake.runtime), - archiveStore: { - list: () => Promise.resolve([]), - get: () => Promise.resolve(undefined), - archive: (input) => { - archivedInputs.push(input.sessionId); - return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }); - }, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }, - sessionManager: { - create: () => fakeSessionManager("/old-project"), - list: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]), - listAll: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]), - open: () => fakeSessionManager("/old-project"), - }, - heartbeatIntervalMs: 60_000, - }); - - await service.status("busy-open"); - const result = await service.cleanup({ thresholds: { archiveIdleDays: 1 } }); - - expect(result.archivedSessionIds).toEqual([]); - expect(result.skippedBusySessionIds).toEqual(["busy-open"]); - expect(archivedInputs).toEqual([]); - expect(fake.calls.abort).toBe(0); - - await service.dispose(); - }); - - it("runs /reload by refreshing the active runtime resources in place", async () => { - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("runtime-reload-session"); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]), - heartbeatIntervalMs: 60_000, - }); - - await expect(service.runCommand(sessionRef("runtime-reload-session"), "/reload")).resolves.toEqual({ - type: "done", - message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.", - }); - - expect(fake.calls.reload).toBe(1); - expect(fake.calls.abort).toBe(0); - expect(fake.calls.dispose).toBe(0); - expect(hub.globalEvents.some((event) => event.type === "activity.update" && event.activity.sessionId === "runtime-reload-session" && event.activity.label === "resources reloaded")).toBe(true); - expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "runtime-reload-session")).toBe(true); - - await service.dispose(); - }); - - it("reloads a session by closing the active runtime and re-opening it from disk", async () => { - const first = fakeRuntime("reload-session"); - const second = fakeRuntime("reload-session"); - const runtimes = [first.runtime, second.runtime]; - let createCalls = 0; - const createAgentRuntime: RuntimeCreator = async () => { - await Promise.resolve(); - const runtime = runtimes[createCalls]; - createCalls += 1; - if (runtime === undefined) throw new Error("unexpected runtime creation"); - return runtime; - }; - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime, - sessionManager: sessionGateway([sessionRecord("reload-session")]), - heartbeatIntervalMs: 60_000, - }); - - // Open once so there is an active runtime to reload. - await service.status(sessionRef("reload-session")); - expect(createCalls).toBe(1); - - await expect(service.reload(sessionRef("reload-session"))).resolves.toBeUndefined(); - - // The original runtime was torn down and a fresh one opened from disk. - expect(first.calls.abort).toBe(1); - expect(first.calls.dispose).toBe(1); - expect(createCalls).toBe(2); - expect(service.activeCount()).toBe(1); - - await service.dispose(); - }); - - it("refuses to reload a session that has active work in progress", async () => { - const fake = fakeRuntime("busy-session", { isStreaming: true }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("busy-session")]), - heartbeatIntervalMs: 60_000, - }); - - await expect(service.reload(sessionRef("busy-session"))).rejects.toThrow("Stop current session activity before reloading"); - expect(fake.calls.abort).toBe(0); - expect(fake.calls.dispose).toBe(0); - - await service.dispose(); - }); - - it("refuses to reload an archived session", async () => { - const service = new PiSessionService(new CapturingSessionEventHub(), { - archiveStore: { - list: () => Promise.resolve([]), - get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) - ? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" } - : undefined), - archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }), - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(true), - }, - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - - await expect(service.reload(sessionRef("archived"))).rejects.toThrow("Archived sessions are read-only"); - - await service.dispose(); - }); - - it("reconciles workspace activity when listing only archived sessions", async () => { - const reconciliations: { cwd: string; sessionIds: string[] }[] = []; - const service = new PiSessionService(new CapturingSessionEventHub(), { - archiveStore: { - list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]), - get: () => Promise.resolve(undefined), - archive: () => { throw new Error("archive should not be called for moved records"); }, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }, - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([]), - open: () => fakeSessionManager(), - }, - workspaceActivity: { - applySessionStatus: () => undefined, - applySessionActivity: () => undefined, - removeSession: () => undefined, - reconcileSessionActivity: (cwd, sessionIds) => { reconciliations.push({ cwd, sessionIds: [...sessionIds] }); }, - }, - heartbeatIntervalMs: 60_000, - }); - - const sessions = await service.list("/workspace"); - - expect(sessions).toHaveLength(1); - expect(sessions[0]).toMatchObject({ id: "archived", archived: true }); - expect(reconciliations).toEqual([{ cwd: "/workspace", sessionIds: [] }]); - - await service.dispose(); - }); - - it("sends prompts to an injected runtime without touching the SDK runtime", async () => { - const fake = fakeRuntime("prompt-session"); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("prompt-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.prompt(sessionRef("prompt-session"), "Build the thing"); - - expect(fake.calls.prompt).toEqual([{ text: "Build the thing", options: undefined }]); - await service.dispose(); - }); - - it("echoes the user message for direct prompts but not command-forwarded ones", async () => { - const fake = fakeRuntime("echo-session", { - resourceLoader: { getSkills: () => ({ skills: [{ name: "skill-creator" }] }) }, - }); - const hub = new CapturingSessionEventHub(); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("echo-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.prompt(sessionRef("echo-session"), "Build the thing"); - expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1); - - // The client optimistically renders command-forwarded prompts (e.g. /skill:*), - // so the server must not publish a second copy via message.append. - await service.runCommand(sessionRef("echo-session"), "/skill:skill-creator"); - expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1); - expect(fake.calls.prompt).toEqual([ - { text: "Build the thing", options: undefined }, - { text: "/skill:skill-creator", options: undefined }, - ]); - - await service.dispose(); - }); - - it("rejects malformed prompt text before opening the runtime", async () => { - const fake = fakeRuntime("prompt-session"); - let createCalls = 0; - const createAgentRuntime: RuntimeCreator = async () => { - createCalls += 1; - await Promise.resolve(); - return fake.runtime; - }; - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime, - sessionManager: sessionGateway([sessionRecord("prompt-session")]), - heartbeatIntervalMs: 60_000, - }); - - await expect(service.prompt("prompt-session", undefined)).rejects.toThrow("Prompt text is required"); - - expect(createCalls).toBe(0); - expect(fake.calls.prompt).toEqual([]); - await service.dispose(); - }); - - it("generates a session name for the first prompt via the session's agent.streamFn", async () => { - const model = testModel(); - const streamCalls: unknown[] = []; - const streamFn: StreamFn = (streamModel, context, options) => { - streamCalls.push({ streamModel, context, options }); - const stream = createAssistantMessageEventStream(); - const message: AssistantMessage = { - role: "assistant", - content: [{ type: "text", text: "Fix login bug" }], - api: "anthropic-messages", - provider: "anthropic", - model: model.id, - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, - stopReason: "stop", - timestamp: Date.now(), - }; - stream.push({ type: "done", reason: "stop", message }); - stream.end(message); - return stream; - }; - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("name-session", { model, agent: { streamFn } }); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("name-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.prompt(sessionRef("name-session"), "Please fix the login bug"); - await vi.waitFor(() => { expect(fake.session.sessionName).toBe("Fix login bug"); }); - - expect(streamCalls).toHaveLength(1); - expect(hub.sessionEvents.some(({ event }) => event.type === "session.name" && event.name === "Fix login bug")).toBe(true); - await service.dispose(); - }); - - it("includes queued message details in session status", async () => { - const fake = fakeRuntime("status-session", { - messages: [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }], - pendingMessageCount: 2, - getSteeringMessages: () => ["adjust this turn"], - getFollowUpMessages: () => ["then do this"], - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("status-session")]), - heartbeatIntervalMs: 60_000, - }); - - await expect(service.status(sessionRef("status-session"))).resolves.toMatchObject({ - pendingMessageCount: 2, - queuedMessages: [{ kind: "steer", text: "adjust this turn" }, { kind: "followUp", text: "then do this" }], - messageCount: 2, - }); - await service.dispose(); - }); - - it("does not enqueue duplicate queued message text", async () => { - const fake = fakeRuntime("dedupe-session", { - isStreaming: true, - pendingMessageCount: 1, - getFollowUpMessages: () => ["already queued"], - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("dedupe-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.prompt(sessionRef("dedupe-session"), "already queued", "followUp"); - - expect(fake.calls.prompt).toEqual([]); - await service.dispose(); - }); - - it("does not append queued prompts to the transcript before delivery", async () => { - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("queued-session", { isStreaming: true }); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("queued-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.prompt(sessionRef("queued-session"), "Wait for the current turn", "followUp"); - - expect(fake.calls.prompt).toEqual([{ text: "Wait for the current turn", options: { streamingBehavior: "followUp" } }]); - expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false); - await service.dispose(); - }); - - it("holds prompts sent during compaction until compaction finishes", async () => { - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("compacting-session", { isCompacting: true }); - let resolveFirstPrompt: (() => void) | undefined; - fake.session.prompt = (text: string, options?: { streamingBehavior?: "steer" | "followUp" }) => { - fake.calls.prompt.push({ text, options }); - if (options === undefined) { - fake.session.isStreaming = true; - return new Promise((resolve) => { resolveFirstPrompt = resolve; }); - } - return Promise.resolve(); - }; - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("compacting-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.prompt(sessionRef("compacting-session"), "Start task 1", "followUp"); - await service.prompt(sessionRef("compacting-session"), "Then task 2", "followUp"); - - expect(fake.calls.prompt).toEqual([]); - expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false); - await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({ - pendingMessageCount: 2, - queuedMessages: [{ kind: "followUp", text: "Start task 1" }, { kind: "followUp", text: "Then task 2" }], - }); - - fake.session.isCompacting = false; - fake.emit({ type: "compaction_end" }); - await new Promise((resolve) => setTimeout(resolve, 5)); - - expect(fake.calls.prompt).toEqual([{ text: "Start task 1", options: undefined }]); - expect(hub.sessionEvents.some(({ event }) => event.type === "message.append" && JSON.stringify(event.message).includes("Start task 1"))).toBe(true); - await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({ - pendingMessageCount: 1, - queuedMessages: [{ kind: "followUp", text: "Then task 2" }], - }); - - fake.emit({ type: "agent_start" }); - await new Promise((resolve) => setTimeout(resolve, 5)); - - expect(fake.calls.prompt).toEqual([ - { text: "Start task 1", options: undefined }, - { text: "Then task 2", options: { streamingBehavior: "followUp" } }, - ]); - await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({ - pendingMessageCount: 0, - queuedMessages: [], - }); - resolveFirstPrompt?.(); - await service.dispose(); - }); - - it("clears queued messages when aborting active work", async () => { - const fake = fakeRuntime("abort-session"); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("abort-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("abort-session")); - await service.abort(sessionRef("abort-session")); - - expect(fake.calls.clearQueue).toBe(1); - expect(fake.calls.abort).toBe(1); - await service.dispose(); - }); - - it("clears prompts queued during compaction when aborting active work", async () => { - const fake = fakeRuntime("abort-compaction-session", { isCompacting: true }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.prompt(sessionRef("abort-compaction-session"), "Do not deliver after abort", "followUp"); - await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 1 }); - await service.abort(sessionRef("abort-compaction-session")); - - expect(fake.calls.clearQueue).toBe(1); - expect(fake.calls.prompt).toEqual([]); - await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 0, queuedMessages: [] }); - await service.dispose(); - }); - - it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => { - const hub = new CapturingSessionEventHub(); - const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } }); - const modelRegistry = ModelRegistry.inMemory(authStorage); - const model = modelRegistry.find("anthropic", "claude-3-5-sonnet-20241022"); - if (model === undefined) throw new Error("Expected Anthropic model fixture"); - const fake = fakeRuntime("auth-session", { model, modelRegistry }); - - const service = new PiSessionService(hub, { - modelRegistry, - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("auth-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("auth-session")); - hub.sessionEvents.length = 0; - hub.globalEvents.length = 0; - - authStorage.logout("anthropic"); - service.applyAuthChange({ removedProviderId: "anthropic" }); - service.applyAuthChange({ removedProviderId: "anthropic" }); - - const warningCount = () => hub.sessionEvents.filter(({ event }) => event.type === "command.output" && event.level === "error" && event.message.includes("anthropic/claude-3-5-sonnet-20241022")).length; - expect(warningCount()).toBe(1); - expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true); - - authStorage.set("anthropic", { type: "api_key", key: "sk-new" }); - service.applyAuthChange(); - authStorage.logout("anthropic"); - service.applyAuthChange({ removedProviderId: "anthropic" }); - expect(warningCount()).toBe(2); - - await service.dispose(); - }); - - it("clears queued messages when stopping a session runtime", async () => { - const fake = fakeRuntime("stop-session"); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("stop-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("stop-session")); - service.stop(sessionRef("stop-session")); - - expect(fake.calls.clearQueue).toBe(1); - await service.dispose(); - }); - - describe("spawnSession", () => { - function spawnService(decision: SpawnTargetDecision) { - const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" }); - const log: { details: Record; message: string }[] = []; - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([]), - spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, - logger: { info: (details, message) => { log.push({ details, message }); } }, - heartbeatIntervalMs: 60_000, - }); - return { fake, service, log }; - } - - it("starts a session at the resolved target, delivers the prompt, and logs the spawn", async () => { - const { fake, service, log } = spawnService({ allowed: true, cwd: "/workspace-feature" }); - - const result = await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue the plan", cwd: "/workspace-feature" }); - - expect(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature" }); - expect(fake.calls.prompt).toEqual([{ text: "continue the plan", options: undefined }]); - expect(log).toEqual([{ details: { spawningCwd: "/workspace", sessionId: "spawned-1", cwd: "/workspace-feature", promptLength: 17 }, message: "spawn_session started a new session" }]); - await service.dispose(); - }); - - it("uses the dispatching session's model as the spawned session's initial model", async () => { - const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" }); - const model = testModel(); - let initialModel: PiAgentSession["model"]; - const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { - await Promise.resolve(); - initialModel = options.initialModel; - return fake.runtime; - }; - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime, - sessionManager: sessionGateway([]), - spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, - heartbeatIntervalMs: 60_000, - }); - - await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue", cwd: "/workspace-feature", model }); - - expect(initialModel).toBe(model); - await service.dispose(); - }); - - it("rejects an out-of-project target without starting a session", async () => { - const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] }); - - await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: "/elsewhere" })) - .rejects.toThrow("cwd must be a workspace of this project. Allowed: /workspace"); - expect(fake.calls.prompt).toEqual([]); - expect(service.activeCount()).toBe(0); - await service.dispose(); - }); - - it("rejects when the spawning session is not in a registered project", async () => { - const { service } = spawnService({ allowed: false, reason: "not-registered" }); - - await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined })) - .rejects.toThrow("Spawning session is not in a registered project"); - await service.dispose(); - }); - - it("is disabled when no spawn target resolver is configured", async () => { - const fake = fakeRuntime("spawned-x"); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - - await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined })) - .rejects.toThrow("Spawning sessions is disabled"); - await service.dispose(); - }); - }); - - describe("spawnSubsession", () => { - function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) { - const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" }); - const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") }); - const created = [parent.runtime, child.runtime]; - let index = 0; - const createAgentRuntime: RuntimeCreator = async () => { - await Promise.resolve(); - const runtime = created[Math.min(index, created.length - 1)] ?? child.runtime; - index += 1; - return runtime; - }; - const archived = new Map(); - const archiveStore = { - list: () => Promise.resolve([...archived.values()]), - get: (sessionId: string) => Promise.resolve(archived.get(sessionId)), - archive: (input: { sessionId: string; cwd: string }) => { - const record = { sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-01T00:00:00.000Z" }; - archived.set(input.sessionId, record); - return Promise.resolve(record); - }, - restore: (sessionId: string) => { archived.delete(sessionId); return Promise.resolve(); }, - isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)), - }; - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime, - sessionManager: sessionGateway([]), - archiveStore, - spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, - heartbeatIntervalMs, - }); - return { parent, child, service }; - } - - it("records the parent, delivers the prompt, and lists the tracked child", async () => { - const { child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); - await service.start("/workspace"); // bring the parent online so it can be notified - - const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" }); - - expect(result).toEqual({ sessionId: "child-1", cwd: "/workspace-feature" }); - expect(child.calls.prompt).toEqual([{ text: "do the slice", options: undefined }]); - await expect(service.listSubsessions("parent-1")).resolves.toEqual([ - { sessionId: "child-1", cwd: "/workspace-feature", status: "idle" }, - ]); - await service.dispose(); - }); - - it("uses the parent session's model as the tracked child's initial model", async () => { - const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" }); - const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") }); - const model = testModel(); - const initialModels: PiAgentSession["model"][] = []; - const runtimes = [parent.runtime, child.runtime]; - let index = 0; - const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { - await Promise.resolve(); - initialModels.push(options.initialModel); - const runtime = runtimes[index] ?? child.runtime; - index += 1; - return runtime; - }; - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime, - sessionManager: sessionGateway([]), - archiveStore: emptyArchiveStore(), - spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model }); - - expect(initialModels).toEqual([undefined, model]); - await service.dispose(); - }); - - it("persists tracked child links in the parent and child sessions", async () => { - const parentPersisted: { customType: string; data?: unknown }[] = []; - const childPersisted: { customType: string; data?: unknown }[] = []; - const parent = fakeRuntime("parent-1", { - sessionFile: "/tmp/parent-1.jsonl", - sessionManager: fakeSessionManager("/workspace", { - appendCustomEntry: (customType, data) => { - parentPersisted.push({ customType, data }); - return "parent-entry-1"; - }, - }), - }); - const child = fakeRuntime("child-1", { - sessionFile: "/tmp/child-1.jsonl", - sessionManager: fakeSessionManager("/workspace-feature", { - appendCustomEntry: (customType, data) => { - childPersisted.push({ customType, data }); - return "child-entry-1"; - }, - }), - }); - const runtimes = [parent.runtime, child.runtime]; - let index = 0; - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { - const runtime = runtimes[index] ?? child.runtime; - index += 1; - return Promise.resolve(runtime); - }, - sessionManager: sessionGateway([]), - archiveStore: emptyArchiveStore(), - spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" }); - - expect(parentPersisted).toEqual([ - { - customType: "pi-web.subsession.link", - data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/tmp/child-1.jsonl", cwd: "/workspace-feature" }, - }, - ]); - expect(childPersisted).toEqual([ - { - customType: "pi-web.subsession.spawned", - data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" }, - }, - ]); - await service.dispose(); - }); - - it("hydrates persisted child links after a service restart so the parent can inspect them", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-")); - const parentFile = join(tempDir, "parent.jsonl"); - const childFile = join(tempDir, "child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - - try { - const childManager = fakeSessionManager("/workspace-feature", { - getBranch: () => [{ type: "message", message: { role: "assistant", content: "finished" } }], - }); - const parent = fakeRuntime("parent-1", { - sessionFile: parentFile, - sessionManager: fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], - }), - }); - const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); - const runtimes = [parent.runtime, child.runtime]; - let index = 0; - const open = vi.fn(() => childManager); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { - const runtime = runtimes[index] ?? child.runtime; - index += 1; - return Promise.resolve(runtime); - }, - sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - await expect(service.checkSubsession("parent-1", "child-1")).resolves.toEqual({ - sessionId: "child-1", - cwd: "/workspace-feature", - status: "idle", - finalText: "finished", - messageCount: 1, - }); - expect(open).toHaveBeenCalledWith(childFile); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("ignores stale persisted child links when the child no longer records the parent", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-")); - const parentFile = join(tempDir, "parent.jsonl"); - const childFile = join(tempDir, "child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8"); - - try { - const parent = fakeRuntime("parent-1", { - sessionFile: parentFile, - sessionManager: fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], - }), - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(parent.runtime), - sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("does not hydrate persisted links when the exact child file is unavailable", async () => { - const parentFile = "/sessions/parent-1.jsonl"; - const parent = fakeRuntime("parent-1", { - sessionFile: parentFile, - sessionManager: fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }], - }), - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(parent.runtime), - sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); - await service.dispose(); - }); - - it("does not hydrate parent links without a child file", async () => { - const parentFile = "/sessions/parent-1.jsonl"; - const parent = fakeRuntime("parent-1", { - sessionFile: parentFile, - sessionManager: fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child", cwd: "/workspace-feature" } }], - }), - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(parent.runtime), - sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); - await service.dispose(); - }); - - it("does not invent subsession links from existing child session headers", async () => { - const parentFile = "/sessions/parent-1.jsonl"; - const childRecord = { ...sessionRecord("child-1", "/workspace-feature"), path: "/sessions/child-1.jsonl", parentSessionPath: parentFile }; - const parent = fakeRuntime("parent-1", { - sessionFile: parentFile, - sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }), - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(parent.runtime), - sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); - await service.dispose(); - }); - - it("does not hydrate copied parent links when the opened parent has a different id", async () => { - const forkedParent = fakeRuntime("parent-fork-1", { - sessionFile: "/sessions/parent-fork-1.jsonl", - sessionManager: fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }], - }), - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(forkedParent.runtime), - sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - await expect(service.listSubsessions("parent-fork-1")).resolves.toEqual([]); - await service.dispose(); - }); - - it("relinks a spawned child when the child session is opened after restart", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-open-child-")); - const parentFile = join(tempDir, "parent.jsonl"); - const childFile = join(tempDir, "child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - - try { - const childManager = fakeSessionManager("/workspace-feature", { - getHeader: () => ({ parentSession: parentFile }), - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], - }); - const parentManager = fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], - }); - const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); - const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); - const runtimes = [child.runtime, parent.runtime]; - let index = 0; - const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { - const runtime = runtimes[index] ?? parent.runtime; - index += 1; - return Promise.resolve(runtime); - }, - sessionManager: { - create: () => childManager, - list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), - listAll: () => Promise.resolve([]), - open, - }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("child-1", "/workspace-feature")); - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(parent.calls.sendCustomMessage).toHaveLength(1); - expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); - expect(open).toHaveBeenCalledWith(parentFile); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("notifies the validated parent file instead of an active prefix-matched parent id", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-prefix-parent-")); - const parentFile = join(tempDir, "parent.jsonl"); - const forkParentFile = join(tempDir, "parent-fork.jsonl"); - const childFile = join(tempDir, "child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(forkParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1-fork", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - - try { - const childManager = fakeSessionManager("/workspace-feature", { - getHeader: () => ({ parentSession: parentFile }), - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], - }); - const parentManager = fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], - }); - const forkManager = fakeSessionManager("/workspace"); - const fork = fakeRuntime("parent-1-fork", { sessionFile: forkParentFile, sessionManager: forkManager }); - const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); - const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); - const runtimes = [fork.runtime, child.runtime, parent.runtime]; - let index = 0; - const open = vi.fn((path: string) => { - if (path === parentFile) return parentManager; - if (path === forkParentFile) return forkManager; - return childManager; - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { - const runtime = runtimes[index] ?? parent.runtime; - index += 1; - return Promise.resolve(runtime); - }, - sessionManager: { - create: () => forkManager, - list: (cwd: string) => Promise.resolve(cwd === "/workspace" - ? [{ ...sessionRecord("parent-1-fork", "/workspace"), path: forkParentFile }] - : [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), - listAll: () => Promise.resolve([]), - open, - }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("parent-1-fork", "/workspace")); - await service.status(sessionRef("child-1", "/workspace-feature")); - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(fork.calls.sendCustomMessage).toHaveLength(0); - expect(parent.calls.sendCustomMessage).toHaveLength(1); - expect(open).toHaveBeenCalledWith(parentFile); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("does not relink a copied child with the original session id unless the parent link names the current child file", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-copied-child-")); - const parentFile = join(tempDir, "parent.jsonl"); - const originalChildFile = join(tempDir, "original-child.jsonl"); - const copiedChildFile = join(tempDir, "copied-child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - - try { - const childManager = fakeSessionManager("/workspace-feature", { - getHeader: () => ({ parentSession: parentFile }), - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], - }); - const parentManager = fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }], - }); - const child = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: childManager }); - const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); - const runtimes = [child.runtime, parent.runtime]; - let index = 0; - const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { - const runtime = runtimes[index] ?? parent.runtime; - index += 1; - return Promise.resolve(runtime); - }, - sessionManager: { - create: () => childManager, - list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }]), - listAll: () => Promise.resolve([]), - open, - }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("child-1", "/workspace-feature")); - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(parent.calls.sendCustomMessage).toHaveLength(0); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("uses the verified child file instead of an active copied child with the same id", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-child-")); - const parentFile = join(tempDir, "parent.jsonl"); - const originalChildFile = join(tempDir, "original-child.jsonl"); - const copiedChildFile = join(tempDir, "copied-child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - - try { - const copiedManager = fakeSessionManager("/workspace-feature", { - getBranch: () => [{ type: "message", message: { role: "assistant", content: "copied child result" } }], - }); - const originalManager = fakeSessionManager("/workspace-feature", { - getBranch: () => [{ type: "message", message: { role: "assistant", content: "original child result" } }], - }); - const parentManager = fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }], - }); - const copiedChild = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: copiedManager, isStreaming: true }); - const originalChild = fakeRuntime("child-1", { sessionFile: originalChildFile, sessionManager: originalManager }); - const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); - const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => { - if (options.sessionManager === copiedManager) return Promise.resolve(copiedChild.runtime); - if (options.sessionManager === originalManager) return Promise.resolve(originalChild.runtime); - if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime); - throw new Error("unexpected session manager"); - }; - const open = vi.fn((path: string) => { - if (path === copiedChildFile) return copiedManager; - if (path === originalChildFile) return originalManager; - if (path === parentFile) return parentManager; - throw new Error(`unexpected open path ${path}`); - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime, - sessionManager: { - create: () => parentManager, - list: (cwd: string) => Promise.resolve(cwd === "/workspace-feature" ? [{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }] : []), - listAll: () => Promise.resolve([]), - open, - }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("child-1", "/workspace-feature")); - await service.start("/workspace"); - - await expect(service.listSubsessions("parent-1", parentFile)).resolves.toEqual([ - { sessionId: "child-1", cwd: "/workspace-feature", status: "idle" }, - ]); - - copiedChild.session.isStreaming = true; - copiedChild.emit({ type: "agent_start" }); - copiedChild.session.isStreaming = false; - copiedChild.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(parent.calls.sendCustomMessage).toHaveLength(0); - - await expect(service.checkSubsession("parent-1", "child-1", parentFile)).resolves.toMatchObject({ - sessionId: "child-1", - cwd: "/workspace-feature", - status: "idle", - finalText: "original child result", - messageCount: 1, - }); - const read = await service.readSubsession("parent-1", "child-1", { roles: ["assistant"] }, parentFile); - expect(read.entries[0]?.parts[0]).toMatchObject({ kind: "text", text: "original child result" }); - expect(open).toHaveBeenCalledWith(originalChildFile); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("uses the verified parent file instead of an active copied parent with the same id", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-parent-")); - const parentFile = join(tempDir, "parent.jsonl"); - const copiedParentFile = join(tempDir, "copied-parent.jsonl"); - const childFile = join(tempDir, "child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(copiedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - - try { - const childManager = fakeSessionManager("/workspace-feature", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], - getBranch: () => [{ type: "message", message: { role: "assistant", content: "child result" } }], - }); - const parentManager = fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], - }); - const copiedParentManager = fakeSessionManager("/workspace", { getEntries: () => [] }); - const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); - const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); - const copiedParent = fakeRuntime("parent-1", { sessionFile: copiedParentFile, sessionManager: copiedParentManager }); - const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => { - if (options.sessionManager === childManager) return Promise.resolve(child.runtime); - if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime); - if (options.sessionManager === copiedParentManager) return Promise.resolve(copiedParent.runtime); - throw new Error("unexpected session manager"); - }; - const open = vi.fn((path: string) => { - if (path === childFile) return childManager; - if (path === parentFile) return parentManager; - if (path === copiedParentFile) return copiedParentManager; - throw new Error(`unexpected open path ${path}`); - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime, - sessionManager: { - create: () => copiedParentManager, - list: (cwd: string) => Promise.resolve(cwd === "/workspace" - ? [{ ...sessionRecord("parent-1", "/workspace"), path: copiedParentFile }] - : [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), - listAll: () => Promise.resolve([]), - open, - }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("child-1", "/workspace-feature")); - await service.status(sessionRef("parent-1", "/workspace")); - - await expect(service.listSubsessions("parent-1", copiedParentFile)).resolves.toEqual([]); - await expect(service.checkSubsession("parent-1", "child-1", copiedParentFile)).rejects.toThrow("not one of your subsessions"); - await expect(service.readSubsession("parent-1", "child-1", {}, copiedParentFile)).rejects.toThrow("not one of your subsessions"); - - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(copiedParent.calls.sendCustomMessage).toHaveLength(0); - expect(parent.calls.sendCustomMessage).toHaveLength(1); - expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); - expect(open).toHaveBeenCalledWith(parentFile); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("does not relink a child marker when the current child file header no longer records the parent", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-child-header-")); - const parentFile = join(tempDir, "parent.jsonl"); - const childFile = join(tempDir, "child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8"); - - try { - const childManager = fakeSessionManager("/workspace-feature", { - getHeader: () => ({ parentSession: parentFile }), - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], - }); - const parentManager = fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], - }); - const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); - const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); - const runtimes = [child.runtime, parent.runtime]; - let index = 0; - const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { - const runtime = runtimes[index] ?? parent.runtime; - index += 1; - return Promise.resolve(runtime); - }, - sessionManager: { - create: () => childManager, - list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), - listAll: () => Promise.resolve([]), - open, - }, - archiveStore: { - ...emptyArchiveStore(), - get: (sessionId) => Promise.resolve(sessionId === "child-1" ? { sessionId: "child-1", cwd: "/workspace-feature", archivedAt: "2026-01-01T00:00:00.000Z", parentSessionPath: parentFile } : undefined), - }, - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("child-1", "/workspace-feature")); - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(parent.calls.sendCustomMessage).toHaveLength(0); - expect(open).not.toHaveBeenCalledWith(parentFile); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("does not relink a child marker when the child header points at a different parent id", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-wrong-parent-")); - const mismatchedParentFile = join(tempDir, "other-parent.jsonl"); - const actualParentFile = join(tempDir, "parent.jsonl"); - const childFile = join(tempDir, "child.jsonl"); - await writeFile(mismatchedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "other-parent", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: mismatchedParentFile })}\n`, "utf8"); - - try { - const childManager = fakeSessionManager("/workspace-feature", { - getHeader: () => ({ parentSession: mismatchedParentFile }), - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], - }); - const parent = fakeRuntime("parent-1", { sessionFile: actualParentFile, sessionManager: fakeSessionManager("/workspace") }); - const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); - const runtimes = [child.runtime, parent.runtime]; - let index = 0; - const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { - const runtime = runtimes[index] ?? parent.runtime; - index += 1; - return Promise.resolve(runtime); - }, - sessionManager: { - create: () => childManager, - list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: mismatchedParentFile }]), - listAll: () => Promise.resolve([{ ...sessionRecord("parent-1", "/workspace"), path: actualParentFile }]), - open, - }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("child-1", "/workspace-feature")); - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(parent.calls.sendCustomMessage).toHaveLength(0); - expect(open).not.toHaveBeenCalledWith(actualParentFile); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("does not relink copied child markers when the opened child has a different id", async () => { - const parentFile = "/sessions/parent-1.jsonl"; - const childFile = "/sessions/child-fork-1.jsonl"; - const childManager = fakeSessionManager("/workspace-feature", { - getHeader: () => ({ parentSession: parentFile }), - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], - }); - const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager }); - const open = vi.fn(() => childManager); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(child.runtime), - sessionManager: { - create: () => childManager, - list: () => Promise.resolve([{ ...sessionRecord("child-fork-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), - listAll: () => Promise.resolve([]), - open, - }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("child-fork-1", "/workspace-feature")); - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(open).not.toHaveBeenCalledWith(parentFile); - await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); - await service.dispose(); - }); - - it("notifies the parent once when the tracked child stops working", async () => { - const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); - await service.start("/workspace"); - await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); - parent.calls.prompt.length = 0; // ignore the spawn prompt to the child; focus on the parent notification - - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); // arm the notification - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); // fire once - child.emit({ type: "turn_end" }); // must not re-notify - await new Promise((resolve) => setTimeout(resolve, 20)); // the parent notification is delivered via the async custom-message path - - expect(parent.calls.sendCustomMessage).toHaveLength(1); - expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); - expect(parent.calls.sendCustomMessage[0]?.message.customType).toBe("subsession.completion"); - expect(parent.calls.sendCustomMessage[0]?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" }); - expect(parent.calls.prompt).toHaveLength(0); // not a user-authored message - await service.dispose(); - }); - - it("notifies via the heartbeat when the child settles without a further event", async () => { - const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }, 10); - await service.start("/workspace"); - await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); - parent.calls.prompt.length = 0; - - // The child works, then settles silently: agent_end arrives while it still - // reports active work, so the event-driven latch does not fire here. - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.emit({ type: "agent_end" }); - expect(parent.calls.sendCustomMessage).toHaveLength(0); - - // Once the session settles, the periodic heartbeat re-check notifies. - child.session.isStreaming = false; - await new Promise((resolve) => setTimeout(resolve, 40)); - - expect(parent.calls.sendCustomMessage).toHaveLength(1); - expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); - await service.dispose(); - }); - - it("does not notify the parent when a tracked child is archived", async () => { - const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); - await service.start("/workspace"); - await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); - // Arm the notification, as a real working child would. - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - parent.calls.sendCustomMessage.length = 0; - - await service.archive("child-1"); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(parent.calls.sendCustomMessage).toHaveLength(0); - await service.dispose(); - }); - - it("reports a missing tracked child file as unknown in the subsession list", async () => { - const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); - await service.start("/workspace"); - await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); - - await service.archive("child-1"); - - await expect(service.listSubsessions("parent-1")).resolves.toEqual([ - { sessionId: "child-1", cwd: "/workspace-feature", status: "unknown" }, - ]); - await service.dispose(); - }); - - it("check_subsession and read_subsession refuse sessions that are not the caller's children", async () => { - const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); - await service.start("/workspace"); - await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); - - await expect(service.checkSubsession("someone-else", "child-1")).rejects.toThrow("not one of your subsessions"); - await expect(service.readSubsession("someone-else", "child-1", {})).rejects.toThrow("not one of your subsessions"); - await service.dispose(); - }); - - it("is disabled when no spawn target resolver is configured", async () => { - const fake = fakeRuntime("nope"); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - await expect(service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "p", parentSessionFile: undefined, prompt: "go", cwd: undefined })) - .rejects.toThrow("Spawning sessions is disabled"); - await service.dispose(); - }); - }); -}); diff --git a/src/server/sessions/piSessionService.testSupport.ts b/src/server/sessions/piSessionService.testSupport.ts new file mode 100644 index 0000000..d058a53 --- /dev/null +++ b/src/server/sessions/piSessionService.testSupport.ts @@ -0,0 +1,162 @@ +import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js"; +import { SessionEventHub } from "../realtime/sessionEventHub.js"; +import type { PiAgentSession, PiSessionManager, PiSessionRuntime, PiSessionServiceDependencies } from "./piSessionService.js"; + +export class CapturingSessionEventHub extends SessionEventHub { + readonly sessionEvents: { sessionId: string; event: SessionUiEvent }[] = []; + readonly globalEvents: GlobalSessionEvent[] = []; + + override publish(sessionId: string, event: SessionUiEvent): void { + this.sessionEvents.push({ sessionId, event }); + } + + override publishGlobal(event: GlobalSessionEvent): void { + this.globalEvents.push(event); + } +} + +export type SessionGateway = NonNullable; +export type RuntimeCreator = NonNullable; + +export interface TestSession extends PiAgentSession { + sessionName: string | undefined; + model: PiAgentSession["model"]; + isStreaming: boolean; + isCompacting: boolean; + isBashRunning: boolean; + pendingMessageCount: number; + getSteeringMessages: () => readonly string[]; + getFollowUpMessages: () => readonly string[]; +} + +export function fakeSessionManager(cwd = "/workspace", patch: Partial = {}): PiSessionManager { + return { + getCwd: () => cwd, + getBranch: () => [], + getLeafId: () => "leaf-1", + ...patch, + }; +} + +export function sessionRecord(id: string, cwd = "/workspace") { + return { id, path: `/sessions/${id}.jsonl`, cwd, created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }; +} + +export function sessionRef(id: string, cwd = "/workspace") { + return { id, cwd }; +} + +export function testModel(): NonNullable { + const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find("anthropic", "claude-3-5-sonnet-20241022"); + if (model === undefined) throw new Error("test model not found"); + return model; +} + +export function fakeRuntime(sessionId = "session-1", patch: Partial = {}) { + const promptCalls: { text: string; options: unknown }[] = []; + const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = []; + const bindExtensionCalls: unknown[] = []; + const listeners: ((event: unknown) => void)[] = []; + const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls }; + const session: TestSession = { + sessionId, + sessionFile: `/tmp/${sessionId}.jsonl`, + messages: [], + sessionName: undefined, + model: undefined, + thinkingLevel: "off", + isStreaming: false, + isCompacting: false, + isBashRunning: false, + pendingMessageCount: 0, + sessionManager: fakeSessionManager(), + modelRegistry: ModelRegistry.create(AuthStorage.inMemory()), + scopedModels: [], + extensionRunner: { getRegisteredCommands: () => [] }, + promptTemplates: [], + resourceLoader: { getSkills: () => ({ skills: [] }) }, + subscribe: (listener: (event: unknown) => void) => { + listeners.push(listener); + return () => { + const index = listeners.indexOf(listener); + if (index !== -1) listeners.splice(index, 1); + }; + }, + bindExtensions: (bindings: unknown) => { + calls.bindExtensions.push(bindings); + return Promise.resolve(); + }, + getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }), + getContextUsage: () => undefined, + reload: () => { + calls.reload += 1; + return Promise.resolve(); + }, + prompt: (text: string, options: unknown) => { + calls.prompt.push({ text, options }); + return Promise.resolve(); + }, + sendCustomMessage: (message: { customType: string; content: string; display: boolean; details?: unknown }, options: unknown) => { + calls.sendCustomMessage.push({ message, options }); + return Promise.resolve(); + }, + executeBash: () => Promise.resolve({ output: "", exitCode: 0, cancelled: false, truncated: false }), + abort: () => { + calls.abort += 1; + return Promise.resolve(); + }, + clearQueue: () => { + calls.clearQueue += 1; + return { steering: [], followUp: [] }; + }, + getSteeringMessages: () => [], + getFollowUpMessages: () => [], + setModel: () => Promise.resolve(), + cycleModel: () => Promise.resolve(undefined), + getAvailableThinkingLevels: () => [], + setThinkingLevel: () => undefined, + cycleThinkingLevel: () => undefined, + setSessionName: (name: string) => { session.sessionName = name; }, + compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }), + getUserMessagesForForking: () => [], + agent: { streamFn: () => { throw new Error("streamFn should not be called in this test"); } }, + ...patch, + }; + const runtime: PiSessionRuntime = { + cwd: session.sessionManager.getCwd(), + session, + setRebindSession: () => undefined, + fork: () => Promise.resolve({ cancelled: false }), + dispose: () => { + calls.dispose += 1; + return Promise.resolve(); + }, + }; + return { runtime, session, calls, emit: (event: unknown) => { for (const listener of [...listeners]) listener(event); } }; +} + +export function runtimeCreator(runtime: PiSessionRuntime): RuntimeCreator { + return async () => { + await Promise.resolve(); + return runtime; + }; +} + +export function sessionGateway(records: ReturnType[]): SessionGateway { + return { + create: () => fakeSessionManager(), + list: () => Promise.resolve(records), + open: () => fakeSessionManager(), + }; +} + +export function emptyArchiveStore(): NonNullable { + return { + list: () => Promise.resolve([]), + get: () => Promise.resolve(undefined), + archive: () => Promise.reject(new Error("archive should not be called")), + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }; +} From 1f26ae3935cc6ea58f07f033d3279490fe3e51f4 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 4 Jul 2026 23:58:32 +0200 Subject: [PATCH 060/111] test(sessions): cover archive lookup edge cases --- .../sessions/sessionArchiveStore.test.ts | 50 ++++++++++++++++++- .../sessions/sessionArchiveTree.test.ts | 8 +-- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/server/sessions/sessionArchiveStore.test.ts b/src/server/sessions/sessionArchiveStore.test.ts index d08186f..c11f579 100644 --- a/src/server/sessions/sessionArchiveStore.test.ts +++ b/src/server/sessions/sessionArchiveStore.test.ts @@ -1,6 +1,6 @@ import { constants } from "node:fs"; import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, describe, expect, it } from "vitest"; import { SessionArchiveStore } from "./sessionArchiveStore.js"; @@ -118,6 +118,54 @@ describe("SessionArchiveStore", () => { } await expect(store.list()).resolves.toEqual([]); }); + + it("prefers exact persisted session IDs over prefix matches and canonicalizes stored cwd", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-web-archive-prefix-")); + tempRoots.push(root); + const archiveFile = join(root, "archived-sessions.json"); + const rawCwd = join(root, "workspace", "..", "workspace"); + await writeFile(archiveFile, JSON.stringify({ + sessions: [ + { + sessionId: "abc123", + cwd: rawCwd, + archivedAt: "2026-01-01T00:00:00.000Z", + originalPath: "/sessions/abc123.jsonl", + archivePath: "/archive/abc123.jsonl", + messageCount: 3, + firstMessage: "prefix", + name: "Prefix match", + parentSessionPath: "/sessions/root.jsonl", + }, + { + sessionId: "abc", + cwd: rawCwd, + archivedAt: "2026-01-01T00:00:00.000Z", + originalPath: "/sessions/abc.jsonl", + archivePath: "/archive/abc.jsonl", + messageCount: 1, + firstMessage: "exact", + }, + ], + }), "utf8"); + + const store = new SessionArchiveStore(archiveFile, join(root, "archived-files")); + + await expect(store.get("abc")).resolves.toMatchObject({ + sessionId: "abc", + cwd: resolve(rawCwd), + firstMessage: "exact", + }); + await expect(store.get("abc1")).resolves.toMatchObject({ + sessionId: "abc123", + cwd: resolve(rawCwd), + firstMessage: "prefix", + name: "Prefix match", + parentSessionPath: "/sessions/root.jsonl", + }); + await expect(store.isArchived("abc1")).resolves.toBe(true); + await expect(store.isArchived("missing")).resolves.toBe(false); + }); }); async function exists(path: string): Promise { diff --git a/src/server/sessions/sessionArchiveTree.test.ts b/src/server/sessions/sessionArchiveTree.test.ts index f34db41..086445a 100644 --- a/src/server/sessions/sessionArchiveTree.test.ts +++ b/src/server/sessions/sessionArchiveTree.test.ts @@ -11,11 +11,11 @@ function candidate(id: string, options: Partial = { } describe("session archive tree planning", () => { - it("finds candidates by full id or prefix", () => { - const candidates = [candidate("abcdef"), candidate("xyz")]; + it("finds candidates by exact id before falling back to a prefix", () => { + const candidates = [candidate("abcdef"), candidate("abc"), candidate("xyz")]; - expect(findArchiveCandidateByIdOrPrefix(candidates, "abcdef")?.id).toBe("abcdef"); - expect(findArchiveCandidateByIdOrPrefix(candidates, "abc")?.id).toBe("abcdef"); + expect(findArchiveCandidateByIdOrPrefix(candidates, "abc")?.id).toBe("abc"); + expect(findArchiveCandidateByIdOrPrefix(candidates, "abcd")?.id).toBe("abcdef"); expect(findArchiveCandidateByIdOrPrefix(candidates, "missing")).toBeUndefined(); }); From 6bfb0a47d3e9eaf062a8ddc28d11d91472cc013b Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 5 Jul 2026 00:01:39 +0200 Subject: [PATCH 061/111] test(machines): cover remote mutation contract --- src/server/machines/machineService.test.ts | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/server/machines/machineService.test.ts b/src/server/machines/machineService.test.ts index 0dfe4c5..9907a8c 100644 --- a/src/server/machines/machineService.test.ts +++ b/src/server/machines/machineService.test.ts @@ -42,6 +42,56 @@ describe("MachineService", () => { await expectOwnerOnlyMachineStore(storePath); }); + it("gets, updates, and removes remote machines without exposing stored secrets", async () => { + const machine = await service.add({ + name: "Remote", + baseUrl: "https://remote.example.test", + token: "initial-secret", + headers: { "X-Pi-Web-Test": "initial" }, + }); + + expect(await service.get(machine.id)).toEqual(machine); + + const updated = await service.update(machine.id, { + name: " Updated Remote ", + baseUrl: "https://updated.example.test/", + token: "updated-secret", + headers: { "X-Pi-Web-Test": "updated" }, + }); + if (updated === undefined) throw new Error("Expected remote machine update to succeed"); + + expect(updated).toMatchObject({ + id: machine.id, + name: "Updated Remote", + kind: "remote", + baseUrl: "https://updated.example.test", + createdAt: machine.createdAt, + }); + expect(updated).not.toHaveProperty("token"); + expect(updated).not.toHaveProperty("headers"); + expect(await service.get(machine.id)).toEqual(updated); + expect(await service.list()).toEqual([expect.objectContaining({ id: "local", kind: "local" }), updated]); + + const persistedAfterUpdate: unknown = JSON.parse(await readFile(storePath, "utf8")); + expect(persistedAfterUpdate).toMatchObject({ + machines: [expect.objectContaining({ + id: machine.id, + name: "Updated Remote", + baseUrl: "https://updated.example.test", + token: "updated-secret", + headers: { "X-Pi-Web-Test": "updated" }, + })], + }); + + await expect(service.remove(machine.id)).resolves.toBe(true); + await expect(service.get(machine.id)).resolves.toBeUndefined(); + await expect(service.remove(machine.id)).resolves.toBe(false); + expect(await service.list()).toEqual([expect.objectContaining({ id: "local", kind: "local" })]); + + const persistedAfterRemove: unknown = JSON.parse(await readFile(storePath, "utf8")); + expect(persistedAfterRemove).toEqual({ machines: [] }); + }); + it.skipIf(process.platform === "win32")("tightens permissions after reading an existing machine store", async () => { await writeFile(storePath, `${JSON.stringify({ machines: [{ From afc0a21bfc643eb930974522bb05c180b7625b1c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 5 Jul 2026 00:06:18 +0200 Subject: [PATCH 062/111] test(client): cover machine add workflow --- .../src/controllers/machineController.test.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/client/src/controllers/machineController.test.ts b/src/client/src/controllers/machineController.test.ts index 40190d4..775d62d 100644 --- a/src/client/src/controllers/machineController.test.ts +++ b/src/client/src/controllers/machineController.test.ts @@ -20,6 +20,15 @@ const remoteMachine: Machine = { updatedAt: "2026-05-26T00:00:00.000Z", }; +const addedMachine: Machine = { + id: "remote-2", + name: "New Remote", + kind: "remote", + baseUrl: "https://new-remote.example.test", + createdAt: "2026-05-27T00:00:00.000Z", + updatedAt: "2026-05-27T00:00:00.000Z", +}; + const offlineHealth: MachineHealth = { machineId: remoteMachine.id, ok: false, @@ -33,6 +42,85 @@ describe("MachineController", () => { vi.restoreAllMocks(); }); + it("selects a newly added machine and clears stale workspace state", async () => { + const project = { id: "p1", name: "Project", path: "/repo", createdAt: "now" }; + const workspace = { id: "w1", projectId: project.id, path: "/repo", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false }; + const session = { id: "s1", cwd: "/repo", path: "/repo/.pi/sessions/s1.json", created: "now", modified: "now", messageCount: 1, firstMessage: "hello" }; + let state: AppState = { + ...initialAppState(), + machines: [localMachine, remoteMachine], + selectedMachine: localMachine, + projects: [project], + workspaces: [workspace], + sessions: [session], + selectedProject: project, + selectedWorkspace: workspace, + selectedSession: session, + fileTree: [{ name: "index.ts", path: "src/index.ts", type: "file" }], + selectedFilePath: "src/index.ts", + gitStatus: { isGitRepo: true, hash: "abc123", branch: "main", files: [{ path: "src/index.ts", index: "modified", workingTree: "modified" }] }, + activeTerminalCount: 2, + error: "stale error", + }; + const setState = (patch: Partial) => { state = { ...state, ...patch }; }; + const updateUrl = vi.fn(); + const projects = { loadProjects: vi.fn() }; + const input = { name: "New Remote", baseUrl: "https://new-remote.example.test", token: "secret-token" }; + + const addMachine = vi.spyOn(api, "addMachine").mockResolvedValue(addedMachine); + const health = vi.spyOn(api, "health").mockResolvedValue({ machineId: addedMachine.id, ok: true, checkedAt: "2026-05-27T00:00:01.000Z", status: "online" }); + const runtime = vi.spyOn(api, "runtime").mockResolvedValue({ machineId: addedMachine.id, ok: true, checkedAt: "2026-05-27T00:00:02.000Z" }); + + const controller = new MachineController(() => state, setState, updateUrl, projects); + + const machine = await controller.addMachine(input); + + expect(machine).toEqual(addedMachine); + expect(addMachine).toHaveBeenCalledWith(input); + expect(state.machines).toEqual([localMachine, remoteMachine, addedMachine]); + expect(state.selectedMachine).toEqual(addedMachine); + expect(state.projects).toEqual([]); + expect(state.workspaces).toEqual([]); + expect(state.sessions).toEqual([]); + expect(state.selectedProject).toBeUndefined(); + expect(state.selectedWorkspace).toBeUndefined(); + expect(state.selectedSession).toBeUndefined(); + expect(state.fileTree).toEqual([]); + expect(state.selectedFilePath).toBeUndefined(); + expect(state.gitStatus).toBeUndefined(); + expect(state.activeTerminalCount).toBe(0); + expect(state.error).toBe(""); + expect(projects.loadProjects).toHaveBeenCalledOnce(); + expect(updateUrl).toHaveBeenCalledOnce(); + expect(health).toHaveBeenCalledWith(addedMachine.id); + expect(runtime).toHaveBeenCalledWith(addedMachine.id); + }); + + it("preserves the current machine state when adding a machine fails", async () => { + let state: AppState = { ...initialAppState(), machines: [localMachine], selectedMachine: localMachine }; + const setState = (patch: Partial) => { state = { ...state, ...patch }; }; + const updateUrl = vi.fn(); + const projects = { loadProjects: vi.fn() }; + const input = { name: "New Remote", baseUrl: "https://new-remote.example.test" }; + + vi.spyOn(api, "addMachine").mockRejectedValue(new Error("Remote rejected")); + const health = vi.spyOn(api, "health"); + const runtime = vi.spyOn(api, "runtime"); + + const controller = new MachineController(() => state, setState, updateUrl, projects); + + const machine = await controller.addMachine(input); + + expect(machine).toBeUndefined(); + expect(state.machines).toEqual([localMachine]); + expect(state.selectedMachine).toEqual(localMachine); + expect(state.error).toBe("Error: Remote rejected"); + expect(projects.loadProjects).not.toHaveBeenCalled(); + expect(updateUrl).not.toHaveBeenCalled(); + expect(health).not.toHaveBeenCalled(); + expect(runtime).not.toHaveBeenCalled(); + }); + it("keeps the routed remote machine selected while its health is offline", async () => { let state: AppState = initialAppState(); const setState = (patch: Partial) => { state = { ...state, ...patch }; }; From 168343a289a85c7204c475ef2948b48039a3575e Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 5 Jul 2026 00:11:22 +0200 Subject: [PATCH 063/111] test(terminals): cover lifecycle publication --- src/server/terminals/terminalService.test.ts | 88 ++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/server/terminals/terminalService.test.ts b/src/server/terminals/terminalService.test.ts index 4ff3986..0b55d1a 100644 --- a/src/server/terminals/terminalService.test.ts +++ b/src/server/terminals/terminalService.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "vitest"; +import type { RealtimeEvent, TerminalInfo } from "../../shared/apiTypes.js"; +import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js"; +import { SessionEventHub } from "../realtime/sessionEventHub.js"; import { TerminalService } from "./terminalService"; // TerminalService spawns a POSIX shell (/bin/bash with -lc and commands like @@ -90,8 +93,93 @@ describe.skipIf(process.platform === "win32")("TerminalService command runs", () service.dispose(); } }); + + it("publishes terminal lifecycle events and workspace activity updates", async () => { + const events = new RecordingEventHub(); + const workspaceActivity = createWorkspaceActivityRecorder(); + const service = new TerminalService(events, workspaceActivity); + const cwd = process.cwd(); + try { + const run = service.runCommand({ + origin: "core", + projectId: "p1", + workspaceId: "w1", + cwd, + title: "Lifecycle command", + command: "true", + }); + const runningTerminal = requireTerminal(service, run.terminalId); + + expect(workspaceActivity.updated).toEqual([{ id: run.terminalId, cwd, exited: false }]); + expect(events.events).toEqual([{ type: "terminal.created", terminal: runningTerminal }]); + + await terminalExit(service, run.terminalId); + const exitedTerminal = requireTerminal(service, run.terminalId); + + expect(workspaceActivity.updated).toEqual([ + { id: run.terminalId, cwd, exited: false }, + { id: run.terminalId, cwd, exited: true }, + ]); + expect(events.events).toEqual([ + { type: "terminal.created", terminal: runningTerminal }, + { type: "terminal.exited", terminal: exitedTerminal }, + ]); + + service.close(run.terminalId); + + expect(workspaceActivity.removed).toEqual([{ terminalId: run.terminalId, cwd }]); + expect(events.events).toEqual([ + { type: "terminal.created", terminal: runningTerminal }, + { type: "terminal.exited", terminal: exitedTerminal }, + { type: "terminal.closed", terminalId: run.terminalId, cwd }, + ]); + } finally { + service.dispose(); + } + }); }); +class RecordingEventHub extends SessionEventHub { + readonly events: RealtimeEvent[] = []; + + override publishRealtime(event: RealtimeEvent): void { + this.events.push(event); + } +} + +interface WorkspaceActivityRecorder extends Pick { + readonly updated: TerminalActivityUpdate[]; + readonly removed: TerminalActivityRemoval[]; +} + +type TerminalActivityUpdate = Pick; + +interface TerminalActivityRemoval { + terminalId: string; + cwd: string | undefined; +} + +function createWorkspaceActivityRecorder(): WorkspaceActivityRecorder { + const updated: TerminalActivityUpdate[] = []; + const removed: TerminalActivityRemoval[] = []; + return { + updated, + removed, + updateTerminal: (terminal) => { + updated.push({ id: terminal.id, cwd: terminal.cwd, exited: terminal.exited }); + }, + removeTerminal: (terminalId, cwd) => { + removed.push({ terminalId, cwd }); + }, + }; +} + +function requireTerminal(service: TerminalService, terminalId: string): TerminalInfo { + const terminal = service.get(terminalId); + if (terminal === undefined) throw new Error(`Expected terminal ${terminalId} to exist`); + return terminal; +} + function terminalReplay(service: TerminalService, terminalId: string): Promise { let output = ""; const detach = service.attach(terminalId, { From 7b11f2ad75f31e0b0b935010d87eaf4b97869178 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 5 Jul 2026 00:16:27 +0200 Subject: [PATCH 064/111] test(server): cover websocket bridge behavior --- src/server/webSocketBridge.test.ts | 119 ++++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 12 deletions(-) diff --git a/src/server/webSocketBridge.test.ts b/src/server/webSocketBridge.test.ts index 43328a7..259fddb 100644 --- a/src/server/webSocketBridge.test.ts +++ b/src/server/webSocketBridge.test.ts @@ -1,38 +1,115 @@ import { afterEach, describe, expect, it } from "vitest"; import { WebSocket, WebSocketServer, type RawData } from "ws"; -import { createBufferedSender } from "./webSocketBridge.js"; +import { bridgeSockets, createBufferedSender } from "./webSocketBridge.js"; -let server: WebSocketServer | undefined; +const servers = new Set(); +const sockets = new Set(); afterEach(async () => { - const socketServer = server; - if (socketServer === undefined) return; - await new Promise((resolve) => { - socketServer.close(() => { resolve(); }); + for (const socket of sockets) closeSocket(socket); + await Promise.all(Array.from(servers, closeSocketServer)); + sockets.clear(); + servers.clear(); +}); + +describe("bridgeSockets", () => { + it("forwards messages in both directions while sockets are open", async () => { + const clientSide = await createSocketPair(); + const upstreamSide = await createSocketPair(); + bridgeSockets(clientSide.bridgeSocket, upstreamSide.bridgeSocket); + + const forwardedToUpstream = nextMessage(upstreamSide.peerSocket); + clientSide.peerSocket.send("to-upstream"); + await expect(forwardedToUpstream).resolves.toBe("to-upstream"); + + const forwardedToClient = nextMessage(clientSide.peerSocket); + upstreamSide.peerSocket.send("to-client"); + await expect(forwardedToClient).resolves.toBe("to-client"); + }); + + it("propagates close and error events to the opposite socket", async () => { + const closeCaseClientSide = await createSocketPair(); + const closeCaseUpstreamSide = await createSocketPair(); + bridgeSockets(closeCaseClientSide.bridgeSocket, closeCaseUpstreamSide.bridgeSocket); + + const upstreamClosed = nextClose(closeCaseUpstreamSide.peerSocket); + closeCaseClientSide.peerSocket.close(); + await upstreamClosed; + + const errorCaseClientSide = await createSocketPair(); + const errorCaseUpstreamSide = await createSocketPair(); + bridgeSockets(errorCaseClientSide.bridgeSocket, errorCaseUpstreamSide.bridgeSocket); + + const clientClosed = nextClose(errorCaseClientSide.peerSocket); + errorCaseUpstreamSide.bridgeSocket.emit("error", new Error("upstream failed")); + await clientClosed; }); - server = undefined; }); describe("createBufferedSender", () => { it("queues messages while a WebSocket is still connecting", async () => { - const socketServer = new WebSocketServer({ host: "127.0.0.1", port: 0 }); - server = socketServer; + const socketServer = createServer(); const connected = new Promise((resolve) => { - socketServer.once("connection", resolve); + socketServer.once("connection", (socket) => { + sockets.add(socket); + resolve(socket); + }); }); await waitForListening(socketServer); const client = new WebSocket(serverUrl(socketServer)); + sockets.add(client); const send = createBufferedSender(client); send("queued-before-open"); const serverSocket = await connected; await expect(nextMessage(serverSocket)).resolves.toBe("queued-before-open"); - client.close(); - serverSocket.close(); + closeSocket(client); + closeSocket(serverSocket); }); }); +interface SocketPair { + bridgeSocket: WebSocket; + peerSocket: WebSocket; +} + +async function createSocketPair(): Promise { + const socketServer = createServer(); + const connected = new Promise((resolve) => { + socketServer.once("connection", (socket) => { + sockets.add(socket); + resolve(socket); + }); + }); + await waitForListening(socketServer); + + const peerSocket = new WebSocket(serverUrl(socketServer)); + sockets.add(peerSocket); + const opened = nextOpen(peerSocket); + const bridgeSocket = await connected; + await opened; + + return { bridgeSocket, peerSocket }; +} + +function createServer(): WebSocketServer { + const socketServer = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + servers.add(socketServer); + return socketServer; +} + +function closeSocket(socket: WebSocket): void { + if (socket.readyState !== WebSocket.CONNECTING && socket.readyState !== WebSocket.OPEN) return; + socket.close(); +} + +function closeSocketServer(socketServer: WebSocketServer): Promise { + return new Promise((resolve) => { + socketServer.close(() => { resolve(); }); + }); +} + function waitForListening(socketServer: WebSocketServer): Promise { if (socketServer.address() !== null) return Promise.resolve(); return new Promise((resolve, reject) => { @@ -50,6 +127,24 @@ function serverUrl(socketServer: WebSocketServer): string { return `ws://127.0.0.1:${String(address.port)}`; } +function nextOpen(socket: WebSocket): Promise { + if (socket.readyState === WebSocket.OPEN) return Promise.resolve(); + return new Promise((resolve, reject) => { + socket.once("error", reject); + socket.once("open", () => { + socket.off("error", reject); + resolve(); + }); + }); +} + +function nextClose(socket: WebSocket): Promise { + if (socket.readyState === WebSocket.CLOSED) return Promise.resolve(); + return new Promise((resolve) => { + socket.once("close", () => { resolve(); }); + }); +} + function nextMessage(socket: WebSocket): Promise { return new Promise((resolve) => { socket.once("message", (data) => { From fad83772b7d2e22f4c0e3766c7ad7f00e2ebe681 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 5 Jul 2026 00:20:17 +0200 Subject: [PATCH 065/111] test(client): cover file explorer tree workflows --- .../fileExplorerController.test.ts | 82 +++++++++++++++++-- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/src/client/src/controllers/fileExplorerController.test.ts b/src/client/src/controllers/fileExplorerController.test.ts index 4217d4c..57b704a 100644 --- a/src/client/src/controllers/fileExplorerController.test.ts +++ b/src/client/src/controllers/fileExplorerController.test.ts @@ -4,6 +4,7 @@ import { WorkspaceUploadBatchError, WorkspaceUploadCancelledError, type FileContentResponse, + type FileTreeEntry, type FileTreeResponse, type Machine, type Project, @@ -13,6 +14,9 @@ import { } from "../api"; import { FileExplorerController, type FileExplorerControllerDependencies } from "./fileExplorerController"; +type FileExplorerApi = NonNullable; +type WorkspaceTree = FileExplorerApi["workspaceTree"]; +type WorkspaceFile = FileExplorerApi["workspaceFile"]; type UploadWorkspaceFiles = NonNullable; type UploadWorkspaceFilesOptions = NonNullable[3]>; @@ -48,6 +52,56 @@ const workspace: Workspace = { isGitWorktree: false, }; +describe("FileExplorerController file tree workflows", () => { + it("refreshes the root and already-expanded directories for the selected machine", async () => { + const rootEntries = [directoryEntry("src"), fileEntry("README.md")]; + const refreshedSrcEntries = [fileEntry("src/index.ts")]; + const refreshedDocsEntries = [fileEntry("docs/guide.md")]; + const workspaceTree = vi.fn((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path, { + "": rootEntries, + src: refreshedSrcEntries, + docs: refreshedDocsEntries, + }[path] ?? []))); + const harness = createHarness({ api: createApi({ workspaceTree }) }, { + expandedDirs: { + src: [fileEntry("src/stale.ts")], + docs: [fileEntry("docs/stale.md")], + }, + fileTreeStale: true, + error: "stale failure", + }); + + await harness.controller.refreshFiles(); + + expect(workspaceTree).toHaveBeenCalledTimes(3); + expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "", "remote-1"); + expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "src", "remote-1"); + expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "docs", "remote-1"); + expect(harness.state.fileTree).toEqual(rootEntries); + expect(harness.state.expandedDirs).toEqual({ src: refreshedSrcEntries, docs: refreshedDocsEntries }); + expect(harness.state.fileTreeStale).toBe(false); + expect(harness.state.error).toBe(""); + }); + + it("expands a directory then collapses it locally without refetching", async () => { + const srcEntries = [fileEntry("src/index.ts")]; + const workspaceTree = vi.fn((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path, srcEntries))); + const harness = createHarness({ api: createApi({ workspaceTree }) }); + + await harness.controller.expandDir("src"); + + expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "src", "remote-1"); + expect(harness.state.expandedDirs).toEqual({ src: srcEntries }); + expect(harness.state.error).toBe(""); + + workspaceTree.mockClear(); + await harness.controller.expandDir("src"); + + expect(workspaceTree).not.toHaveBeenCalled(); + expect(harness.state.expandedDirs).toEqual({}); + }); +}); + describe("FileExplorerController workspace uploads", () => { it("tracks upload progress, completes from final responses, refreshes files, and selects the first uploaded file", async () => { const upload = controllableUpload(); @@ -227,18 +281,16 @@ describe("FileExplorerController workspace uploads", () => { }); }); -function createHarness(deps: FileExplorerControllerDependencies = {}) { +function createHarness(deps: FileExplorerControllerDependencies = {}, statePatch: Partial = {}) { installWindow("http://localhost/app"); let state: AppState = { ...initialAppState(), selectedMachine: machine, selectedProject: project, selectedWorkspace: workspace, + ...statePatch, }; - const api: NonNullable = deps.api ?? { - workspaceTree: vi.fn["workspaceTree"]>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path))), - workspaceFile: vi.fn["workspaceFile"]>((_projectId, _workspaceId, path) => Promise.resolve(fileResponse(path))), - }; + const api = deps.api ?? createApi(); const updateUrl = vi.fn(); let batchSequence = 0; const controller = new FileExplorerController( @@ -308,8 +360,24 @@ function sequenceNow(...values: string[]): () => string { return () => values[index++] ?? values.at(-1) ?? "now"; } -function treeResponse(path: string): FileTreeResponse { - return { path, entries: [], scannedAt: "2026-06-25T00:00:00.000Z", truncated: false }; +function createApi(overrides: Partial = {}): FileExplorerApi { + return { + workspaceTree: vi.fn((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path))), + workspaceFile: vi.fn((_projectId, _workspaceId, path) => Promise.resolve(fileResponse(path))), + ...overrides, + }; +} + +function treeResponse(path: string, entries: FileTreeEntry[] = []): FileTreeResponse { + return { path, entries, scannedAt: "2026-06-25T00:00:00.000Z", truncated: false }; +} + +function directoryEntry(path: string): FileTreeEntry { + return { name: path.split("/").at(-1) ?? path, path, type: "directory" }; +} + +function fileEntry(path: string): FileTreeEntry { + return { name: path.split("/").at(-1) ?? path, path, type: "file", size: 2 }; } function fileResponse(path: string): FileContentResponse { From a6e0f1db18049aef4fca45498e6feb7af76b1043 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 5 Jul 2026 00:26:34 +0200 Subject: [PATCH 066/111] test(client): cover workspace files panel tree boundary --- .../components/WorkspaceFilesPanel.test.ts | 155 ++++++++++++++---- 1 file changed, 126 insertions(+), 29 deletions(-) diff --git a/src/client/src/components/WorkspaceFilesPanel.test.ts b/src/client/src/components/WorkspaceFilesPanel.test.ts index 937989c..6fcbdaf 100644 --- a/src/client/src/components/WorkspaceFilesPanel.test.ts +++ b/src/client/src/components/WorkspaceFilesPanel.test.ts @@ -1,5 +1,6 @@ import type { TemplateResult } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { FileContentResponse, FileTreeEntry } from "../api"; import { initialAppState } from "../appState"; import type { WorkspacePanelContext } from "../plugins/types"; import type { WorkspaceUploadBatchState } from "../workspaceUploadState"; @@ -39,6 +40,38 @@ describe("workspace-files-panel upload review", () => { }); }); +describe("workspace-files-panel file tree boundary", () => { + it("renders expanded tree and selected-file state while wiring row clicks", () => { + const onExpandDir = vi.fn(); + const onSelectFile = vi.fn(); + const panel = new WorkspaceFilesPanel(); + panel.context = workspacePanelContext({ + fileTree: [directoryEntry("src"), fileEntry("README.md", 4096)], + expandedDirs: { src: [fileEntry("src/main.ts")] }, + selectedFilePath: "README.md", + selectedFileContent: binaryFileContent("README.md", 4096), + onExpandDir, + onSelectFile, + }); + + const rendered = panel.render(); + const text = collectTemplateText(rendered); + + expect(text).toContain("▾"); + expect(text).toContain("src"); + expect(text).toContain("main.ts"); + expect(text).toContain("README.md"); + expect(text).toContain("Binary file: README.md · 4.0 KB"); + expect(text).not.toContain("Select a file."); + + findTemplateClickHandlerForText(rendered, "src")(new Event("click")); + findTemplateClickHandlerForText(rendered, "README.md")(new Event("click")); + + expect(onExpandDir).toHaveBeenCalledWith("src"); + expect(onSelectFile).toHaveBeenCalledWith("README.md"); + }); +}); + describe("workspaceUploadBatchesForScope", () => { it("filters upload batches to the selected project, workspace, and machine", () => { const matchingOlder = uploadBatch({ id: "older", startedAt: "2026-06-25T00:00:00.000Z" }); @@ -154,6 +187,50 @@ function findOptionalTemplateEventHandler(template: TemplateRes } } +// Node-based Lit tests cannot click shadow DOM here; keep direct handler extraction +// anchored to rendered file labels and assert the observable context callbacks. +function findTemplateClickHandlerForText(template: TemplateResult, text: string): TemplateEventHandler { + const handler = findOptionalTemplateClickHandlerForText(template, text); + if (handler === undefined) throw new Error(`Expected click handler near ${text}`); + return handler; +} + +function findOptionalTemplateClickHandlerForText(value: unknown, text: string): TemplateEventHandler | undefined { + if (Array.isArray(value)) { + for (const item of value) { + const nestedHandler = findOptionalTemplateClickHandlerForText(item, text); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; + } + if (!isTemplateResult(value)) return undefined; + + for (const item of templateValues(value)) { + const nestedHandler = findOptionalTemplateClickHandlerForText(item, text); + if (nestedHandler !== undefined) return nestedHandler; + } + if (!collectTemplateText(value).includes(text)) return undefined; + + const strings = templateStrings(value); + const values = templateValues(value); + for (let index = 0; index < values.length; index += 1) { + const staticChunk = strings[index]; + const candidate = values[index]; + if (staticChunk !== undefined && staticChunk.includes("@click") && isTemplateEventHandler(candidate)) return candidate; + } + return undefined; +} + +function collectTemplateText(value: unknown): string { + if (Array.isArray(value)) return value.map((item) => collectTemplateText(item)).join(""); + if (isTemplateResult(value)) { + const strings = templateStrings(value); + const values = templateValues(value); + return strings.map((part, index) => `${part}${index < values.length ? collectTemplateText(values[index]) : ""}`).join(""); + } + return typeof value === "string" || typeof value === "number" ? String(value) : ""; +} + function templateStrings(template: TemplateResult): readonly string[] { const strings = Reflect.get(template, "strings"); if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); @@ -222,44 +299,64 @@ class FakeSubmitEvent extends Event implements SubmitEvent { readonly submitter: HTMLElement | null = null; } -function workspacePanelContext(patch: Partial> = {}): WorkspacePanelContext { - const workspace = { id: "workspace-1", projectId: "project-1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false }; +function fileEntry(path: string, size = 2): FileTreeEntry { + return { name: path.split("/").at(-1) ?? path, path, type: "file", size }; +} + +function directoryEntry(path: string): FileTreeEntry { + return { name: path.split("/").at(-1) ?? path, path, type: "directory" }; +} + +function binaryFileContent(path: string, size: number): FileContentResponse { return { - machine: { id: "local", name: "Local", kind: "local" }, + path, + encoding: "utf8", + size, + modifiedAt: "2026-06-25T00:00:00.000Z", + content: "", + truncated: false, + binary: true, + }; +} + +function workspacePanelContext(patch: Partial = {}): WorkspacePanelContext { + const workspace = patch.workspace ?? { id: "workspace-1", projectId: "project-1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false }; + return { + machine: patch.machine ?? { id: "local", name: "Local", kind: "local" }, workspace, - state: { ...initialAppState(), workspaceUploadBatches: {} }, - files: { + state: patch.state ?? { ...initialAppState(), workspaceUploadBatches: {} }, + files: patch.files ?? { readFile: vi.fn(() => Promise.reject(new Error("not implemented"))), writeFile: vi.fn(() => Promise.reject(new Error("not implemented"))), deleteFile: vi.fn(() => Promise.reject(new Error("not implemented"))), moveFile: vi.fn(() => Promise.reject(new Error("not implemented"))), }, - prompt: { insertText: vi.fn(), getText: vi.fn(() => ""), getSelection: vi.fn(() => null) }, - terminal: { open: vi.fn(), runCommand: vi.fn(() => Promise.reject(new Error("not implemented"))) }, - host: { requestRender: vi.fn() }, - fileTree: [], - expandedDirs: {}, - selectedFilePath: undefined, - selectedFileContent: undefined, - fileTreeStale: false, - gitStatus: undefined, - selectedDiffPath: undefined, - selectedDiff: undefined, - selectedStagedDiff: undefined, - gitStale: false, - activeTerminalCount: 0, - selectedTerminalId: undefined, - terminalAutoStart: false, + prompt: patch.prompt ?? { insertText: vi.fn(), getText: vi.fn(() => ""), getSelection: vi.fn(() => null) }, + terminal: patch.terminal ?? { open: vi.fn(), runCommand: vi.fn(() => Promise.reject(new Error("not implemented"))) }, + host: patch.host ?? { requestRender: vi.fn() }, + fileTree: patch.fileTree ?? [], + expandedDirs: patch.expandedDirs ?? {}, + selectedFilePath: patch.selectedFilePath, + selectedFileContent: patch.selectedFileContent, + fileTreeStale: patch.fileTreeStale ?? false, + gitStatus: patch.gitStatus, + selectedDiffPath: patch.selectedDiffPath, + selectedDiff: patch.selectedDiff, + selectedStagedDiff: patch.selectedStagedDiff, + gitStale: patch.gitStale ?? false, + activeTerminalCount: patch.activeTerminalCount ?? 0, + selectedTerminalId: patch.selectedTerminalId, + terminalAutoStart: patch.terminalAutoStart ?? false, workspaceUploadDefaultFolder: patch.workspaceUploadDefaultFolder ?? ".pi-web/uploads", - onRefreshFiles: vi.fn(), - onExpandDir: vi.fn(), - onSelectFile: vi.fn(), + onRefreshFiles: patch.onRefreshFiles ?? vi.fn(), + onExpandDir: patch.onExpandDir ?? vi.fn(), + onSelectFile: patch.onSelectFile ?? vi.fn(), onStartWorkspaceUpload: patch.onStartWorkspaceUpload ?? vi.fn(() => undefined), - onCancelWorkspaceUpload: vi.fn(), - onClearWorkspaceUpload: vi.fn(), - onRefreshGit: vi.fn(), - onSelectDiff: vi.fn(), - onSelectTerminal: vi.fn(), + onCancelWorkspaceUpload: patch.onCancelWorkspaceUpload ?? vi.fn(), + onClearWorkspaceUpload: patch.onClearWorkspaceUpload ?? vi.fn(), + onRefreshGit: patch.onRefreshGit ?? vi.fn(), + onSelectDiff: patch.onSelectDiff ?? vi.fn(), + onSelectTerminal: patch.onSelectTerminal ?? vi.fn(), }; } From c0538cff65b1444ec55c464ccccacede3849c91c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 5 Jul 2026 00:41:38 +0200 Subject: [PATCH 067/111] test(client): cover prompt attachment paste wiring --- .../src/promptAttachmentCapture.test.ts | 88 ++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/src/client/src/promptAttachmentCapture.test.ts b/src/client/src/promptAttachmentCapture.test.ts index d25ee37..d466734 100644 --- a/src/client/src/promptAttachmentCapture.test.ts +++ b/src/client/src/promptAttachmentCapture.test.ts @@ -82,7 +82,46 @@ describe("effectivePromptAttachmentDelivery", () => { }); }); -describe("PromptEditor attachment chips", () => { +describe("PromptEditor attachment wiring", () => { + // Direct TemplateResult handler extraction keeps these node-environment tests focused on + // PromptEditor wiring without introducing a DOM/FileReader harness for the whole component. + it("captures pasted files, strips data URL prefixes, and surfaces read failures", async () => { + const editor = new PromptEditor(); + const onSend = vi.fn>(); + editor.onSend = onSend; + setPromptEditorPrivate(editor, "draft", "inspect attachments"); + const restoreFileReader = installFileReaderStub([ + { kind: "load", result: "data:image/png;base64,UE5H" }, + { kind: "error", error: new DOMException("File unavailable", "NotReadableError") }, + ]); + + try { + const paste = findTemplateEventHandlerAfterMarker(editor.render(), "@paste="); + const pasteEvent = pasteEventWithFiles([ + new File(["png"], "shot.png", { type: "image/png" }), + new File(["pdf"], "report.pdf", { type: "application/pdf" }), + ]); + const preventDefault = vi.spyOn(pasteEvent, "preventDefault"); + + paste(pasteEvent); + await flushMicrotasks(); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(templateContainsValue(editor.render(), "Remove shot.png")).toBe(true); + expect(templateContainsValue(editor.render(), READ_FAILURE_MESSAGE)).toBe(true); + + const send = findTemplateEventHandlerAfterMarker(editor.render(), "send-button"); + send(new Event("click")); + + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith("inspect attachments", undefined, [ + { kind: "image", mimeType: "image/png", data: "UE5H", name: "shot.png" }, + ], "inline"); + } finally { + restoreFileReader(); + } + }); + it("removes a pending attachment chip before sending the remaining attachments", () => { const editor = new PromptEditor(); const onSend = vi.fn>(); @@ -111,10 +150,57 @@ describe("PromptEditor attachment chips", () => { type TemplateEventHandler = (event: E) => void; +type StubFileReaderOutcome = + | { kind: "load"; result: string } + | { kind: "error"; error: DOMException }; + function setPromptEditorPrivate(editor: PromptEditor, property: string, value: unknown): void { if (!Reflect.set(editor, property, value)) throw new Error(`Failed to set PromptEditor ${property}`); } +function installFileReaderStub(outcomes: StubFileReaderOutcome[]): () => void { + const hadFileReader = Reflect.has(globalThis, "FileReader"); + const previousFileReader = Reflect.get(globalThis, "FileReader"); + + class StubFileReader { + onerror: (() => void) | null = null; + onload: (() => void) | null = null; + error: DOMException | null = null; + result: string | ArrayBuffer | null = null; + + readAsDataURL(): void { + const outcome = outcomes.shift(); + if (outcome === undefined) throw new Error("Unexpected FileReader.readAsDataURL call"); + if (outcome.kind === "error") { + this.error = outcome.error; + this.onerror?.(); + return; + } + this.result = outcome.result; + this.onload?.(); + } + } + + Reflect.set(globalThis, "FileReader", StubFileReader); + return () => { + if (hadFileReader) { + Reflect.set(globalThis, "FileReader", previousFileReader); + return; + } + Reflect.deleteProperty(globalThis, "FileReader"); + }; +} + +function pasteEventWithFiles(files: readonly File[]): Event { + const event = new Event("paste", { cancelable: true }); + Object.defineProperty(event, "clipboardData", { value: { files } }); + return event; +} + +async function flushMicrotasks(): Promise { + for (let remaining = 0; remaining < 10; remaining += 1) await Promise.resolve(); +} + function findTemplateEventHandlerAfterMarker(template: TemplateResult, marker: string): TemplateEventHandler { const handler = findOptionalTemplateEventHandlerAfterMarker(template, marker); if (handler === undefined) throw new Error(`Expected template event handler after marker ${marker}`); From 6b61596ebd2c6fdbc69cb9dd764af6390046cd9f Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 5 Jul 2026 00:49:45 +0200 Subject: [PATCH 068/111] test(sessions): cover attachment filename safety --- src/server/sessions/attachmentService.test.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/server/sessions/attachmentService.test.ts b/src/server/sessions/attachmentService.test.ts index a41601d..4281e6c 100644 --- a/src/server/sessions/attachmentService.test.ts +++ b/src/server/sessions/attachmentService.test.ts @@ -1,5 +1,5 @@ import { mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { formatDimensionNote, resizeImage, type ResizedImage } from "@earendil-works/pi-coding-agent"; @@ -134,6 +134,27 @@ describe("saveAttachmentsToWorkspace", () => { expect(await readFile(join(workspace, saved[1]?.path ?? ""))).toHaveLength(0); }); + it("falls back, strips controls, and truncates unsafe attachment names", async () => { + const longStem = "a".repeat(140); + const saved = await saveAttachmentsToWorkspace( + workspace, + [ + { kind: "image", mimeType: "image/jpeg", data: pngBase64 }, + { kind: "file", mimeType: "application/octet-stream", data: "QUJD", name: "\u0000\u001f\u007f" }, + { kind: "file", mimeType: "text/plain", data: "REVG", name: "nested/bad\u0000\u007fname\n.txt" }, + { kind: "file", mimeType: "application/pdf", data: "R0hJ", name: `${longStem}.pdf` }, + ], + { now: () => new Date(2026, 5, 13, 12, 5, 1, 123) }, + ); + + expect(saved.map((attachment) => basename(attachment.path))).toEqual([ + "attachment-20260613-120501-123-1-image.jpg", + "attachment-20260613-120501-123-2-file.bin", + "attachment-20260613-120501-123-3-badname.txt", + `attachment-20260613-120501-123-4-${"a".repeat(92)}.pdf`, + ]); + }); + it("does not overwrite an existing attachment name", async () => { const fixedNow = () => new Date("2026-06-13T12:05:01.123Z"); const first = await saveAttachmentsToWorkspace( From b0d4942b8d5067b3ddb3702004c64cda5034ed2f Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 5 Jul 2026 01:00:25 +0200 Subject: [PATCH 069/111] test(client): cover workspace upload helper options --- src/client/src/api/workspaceUploads.test.ts | 41 +++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/client/src/api/workspaceUploads.test.ts b/src/client/src/api/workspaceUploads.test.ts index e36e855..e55226f 100644 --- a/src/client/src/api/workspaceUploads.test.ts +++ b/src/client/src/api/workspaceUploads.test.ts @@ -100,6 +100,43 @@ describe("workspace upload helpers", () => { ]); }); + it("forwards createDirs through batch upload requests", async () => { + const xhrs = new FakeXhrQueue(); + const file = new File(["hello"], "nested.txt", { type: "text/plain" }); + + const task = uploadWorkspaceFiles("p1", "w1", [file], { + destinationFolder: "uploads", + createDirs: false, + xhrFactory: xhrs.factory, + }); + + const xhr = xhrs.only(); + expect(xhr.url).toBe("/api/machines/local/projects/p1/workspaces/w1/file?path=uploads%2Fnested.txt&createDirs=false"); + xhr.respondJson(200, { path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }); + + await expect(task.promise).resolves.toEqual([ + { path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }, + ]); + }); + + it("cancels an in-flight batch upload without starting remaining files", async () => { + const xhrs = new FakeXhrQueue(); + const files = [new File(["ab"], "a.txt"), new File(["cde"], "b.txt")]; + + const task = uploadWorkspaceFiles("p1", "w1", files, { + destinationFolder: "uploads", + xhrFactory: xhrs.factory, + }); + const first = xhrs.only(); + const cancellation = expect(task.promise).rejects.toBeInstanceOf(WorkspaceUploadCancelledError); + + task.cancel(); + + await cancellation; + expect(first.aborted).toBe(true); + expect(xhrs.count()).toBe(1); + }); + it("continues batch uploads after per-file failures and reports the failed file only", async () => { const xhrs = new FakeXhrQueue(); const progress: WorkspaceUploadBatchProgress[] = []; @@ -146,6 +183,10 @@ class FakeXhrQueue { at(index: number): FakeXMLHttpRequest { return this.instances[index] ?? failTest(`missing XHR instance ${String(index)}`); } + + count(): number { + return this.instances.length; + } } class FakeXMLHttpRequest implements WorkspaceUploadXhr { From 856ba7345b4faf5acaa516c44eefec3d20d52769 Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Tue, 7 Jul 2026 21:35:41 +0000 Subject: [PATCH 070/111] feat(docker): include vim in default images --- .changeset/docker-image-vim.md | 5 +++++ docker/README.md | 2 +- docker/internal/image/install-opensuse-base | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/docker-image-vim.md diff --git a/.changeset/docker-image-vim.md b/.changeset/docker-image-vim.md new file mode 100644 index 0000000..5c35fcf --- /dev/null +++ b/.changeset/docker-image-vim.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Include Vim in the default Docker runtime and development images. diff --git a/docker/README.md b/docker/README.md index 17f3a96..8d89d97 100644 --- a/docker/README.md +++ b/docker/README.md @@ -125,7 +125,7 @@ The installer also writes a generated `compose.override.yml` in the install dire ### Base image and tooling -The Docker runtime and development images are openSUSE Tumbleweed based by default. They install Node.js 22, npm, `npx`, and Corepack through zypper, using the openSUSE Node.js build service repository when needed for the selected architecture. The image's `pi-web` account is created with `PI_WEB_UID:PI_WEB_GID` and `/data/home` as its home directory, so shells have a passwd entry instead of showing `I have no name!` while user config stays in the persistent `/data` mount. The image also includes common agent/development tools such as Git/Git LFS, GitHub CLI, OpenSSH, Python with pip/virtualenv and headers, native build tooling, `jq`, `ripgrep`, `fd`, `fzf`, `bat`, ShellCheck, archive tools, network utilities, and the Docker CLI with Compose and Buildx plugins. +The Docker runtime and development images are openSUSE Tumbleweed based by default. They install Node.js 22, npm, `npx`, and Corepack through zypper, using the openSUSE Node.js build service repository when needed for the selected architecture. The image's `pi-web` account is created with `PI_WEB_UID:PI_WEB_GID` and `/data/home` as its home directory, so shells have a passwd entry instead of showing `I have no name!` while user config stays in the persistent `/data` mount. The image also includes common agent/development tools such as Git/Git LFS, GitHub CLI, OpenSSH, Python with pip/virtualenv and headers, native build tooling, `jq`, `ripgrep`, `fd`, `fzf`, `bat`, `vim`, ShellCheck, archive tools, network utilities, and the Docker CLI with Compose and Buildx plugins. Install extra distro packages without writing a hook by setting a whitespace-delimited package list: diff --git a/docker/internal/image/install-opensuse-base b/docker/internal/image/install-opensuse-base index 94acd17..f82c251 100755 --- a/docker/internal/image/install-opensuse-base +++ b/docker/internal/image/install-opensuse-base @@ -83,6 +83,7 @@ packages=( fd fzf bat + vim ShellCheck less file From 829b3508a49cb27f149f87223bf35259274e2096 Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Wed, 8 Jul 2026 20:33:58 +0000 Subject: [PATCH 071/111] fix: repair Docker installer asset fetch --- .../fix-docker-installer-asset-fetch.md | 5 ++ docker/install.sh | 10 +-- src/server/dockerControlAssets.test.ts | 68 +++++++++++++++++++ 3 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-docker-installer-asset-fetch.md diff --git a/.changeset/fix-docker-installer-asset-fetch.md b/.changeset/fix-docker-installer-asset-fetch.md new file mode 100644 index 0000000..4186488 --- /dev/null +++ b/.changeset/fix-docker-installer-asset-fetch.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Fix the Docker runtime installer so one-line installs can fetch Docker assets into a fresh install directory. diff --git a/docker/install.sh b/docker/install.sh index 128e398..8823893 100755 --- a/docker/install.sh +++ b/docker/install.sh @@ -231,12 +231,14 @@ dotenv_quote() { } fetch_url() { - url=$1 - target=$2 + # POSIX sh function variables are global, so keep these names distinct + # from caller state such as write_asset's target path. + fetch_url_source=$1 + fetch_url_output=$2 if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" -o "$target" + curl -fsSL "$fetch_url_source" -o "$fetch_url_output" elif command -v wget >/dev/null 2>&1; then - wget -qO "$target" "$url" + wget -qO "$fetch_url_output" "$fetch_url_source" else die "curl or wget is required to fetch Docker assets" fi diff --git a/src/server/dockerControlAssets.test.ts b/src/server/dockerControlAssets.test.ts index 912bc18..35f1965 100644 --- a/src/server/dockerControlAssets.test.ts +++ b/src/server/dockerControlAssets.test.ts @@ -82,6 +82,37 @@ describe("Docker command assets", () => { expect(devCompose).toContain("COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web-dev}"); }); + dockerCommandIt("fetches remote installer assets without clobbering the write target", async () => { + const installDir = join(tempDir, "remote-runtime"); + const fakeDocker = await installFakeDocker(); + await installFakeCurl(fakeDocker.binDir); + await installFakeUname(fakeDocker.binDir, "Darwin"); + const home = join(tempDir, "home"); + const socketPath = join(home, ".docker", "run", "docker.sock"); + + await withUnixSocket(socketPath, async () => { + await execUtf8("sh", [ + join(repoRoot, "docker", "install.sh"), + "--install-dir", installDir, + "--data-dir", join(installDir, "data"), + "--asset-ref", "test-assets", + "--skip-compose", + ], { + ...cleanProcessEnv(), + PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`, + HOME: home, + FAKE_DOCKER_LOG: fakeDocker.logPath, + PI_WEB_DOCKER_ASSET_BASE: "https://assets.example.test/docker", + }); + }); + + expect(await readFile(join(installDir, "Dockerfile"), "utf8")).toContain("COPY pi-web-docker /usr/local/bin/pi-web-docker"); + expect(await readFile(join(installDir, "pi-web-docker"), "utf8")).toContain("Usage: pi-web-docker"); + const env = await readFile(join(installDir, ".env"), "utf8"); + expect(env).toContain(`PI_WEB_DOCKER_INSTALL_DIR=${installDir}`); + expect(env).toContain("PI_WEB_DOCKER_REF=test-assets"); + }); + dockerCommandIt("runs status through Docker Compose in the foreground", async () => { const installDir = await createRuntimeInstall(); const fakeDocker = await installFakeDocker(); @@ -492,6 +523,43 @@ exit 9 return { binDir, logPath }; } +async function installFakeCurl(binDir: string): Promise { + const curlPath = join(binDir, "curl"); + await writeFile(curlPath, `#!/usr/bin/env sh +set -eu +asset_root=${shellSingleQuote(join(repoRoot, "docker"))} +output= +url= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) + shift + output=\${1:-} + ;; + -*) + ;; + *) + url=$1 + ;; + esac + if [ "$#" -gt 0 ]; then + shift + fi +done +[ -n "$url" ] || { printf '%s\n' "fake curl missing URL" >&2; exit 2; } +[ -n "$output" ] || { printf '%s\n' "fake curl missing -o output" >&2; exit 2; } +case "$url" in + */docker/*) rel=\${url##*/docker/} ;; + *) printf 'unexpected fake curl url: %s\n' "$url" >&2; exit 2 ;; +esac +src=$asset_root/$rel +[ -f "$src" ] || { printf 'missing fake curl asset: %s\n' "$src" >&2; exit 2; } +mkdir -p "$(dirname "$output")" +cp "$src" "$output" +`, "utf8"); + await chmod(curlPath, 0o755); +} + async function installFakeUname(binDir: string, osName: string): Promise { const unamePath = join(binDir, "uname"); await writeFile(unamePath, `#!/usr/bin/env sh From d6cfffd70d586a409efec0452527f18623a2a553 Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Tue, 7 Jul 2026 21:55:19 +0000 Subject: [PATCH 072/111] fix: support clipboard copy on private HTTP origins --- .changeset/chat-copy-insecure-contexts.md | 5 + src/client/src/clipboard.test.ts | 46 ++++++++ src/client/src/clipboard.ts | 101 ++++++++++++++++++ src/client/src/components/ChatView.ts | 13 +-- src/client/src/components/FormattedText.ts | 13 +-- .../src/components/ToolExecutionView.ts | 11 +- 6 files changed, 164 insertions(+), 25 deletions(-) create mode 100644 .changeset/chat-copy-insecure-contexts.md create mode 100644 src/client/src/clipboard.test.ts create mode 100644 src/client/src/clipboard.ts diff --git a/.changeset/chat-copy-insecure-contexts.md b/.changeset/chat-copy-insecure-contexts.md new file mode 100644 index 0000000..67af395 --- /dev/null +++ b/.changeset/chat-copy-insecure-contexts.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Allow chat copy buttons to work from HTTP private-network addresses by falling back when the browser Clipboard API is unavailable. diff --git a/src/client/src/clipboard.test.ts b/src/client/src/clipboard.test.ts new file mode 100644 index 0000000..8d7d543 --- /dev/null +++ b/src/client/src/clipboard.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; +import { writeClipboardText } from "./clipboard"; + +describe("writeClipboardText", () => { + it("uses the synchronous fallback directly in insecure contexts", async () => { + const writeText = vi.fn(() => Promise.resolve()); + const fallbackWriteText = vi.fn(() => true); + + const copied = await writeClipboardText("hello", { isSecureContext: false, writeText, fallbackWriteText }); + + expect(copied).toBe(true); + expect(writeText).not.toHaveBeenCalled(); + expect(fallbackWriteText).toHaveBeenCalledWith("hello"); + }); + + it("uses the async Clipboard API in secure contexts", async () => { + const writeText = vi.fn(() => Promise.resolve()); + const fallbackWriteText = vi.fn(() => true); + + const copied = await writeClipboardText("hello", { isSecureContext: true, writeText, fallbackWriteText }); + + expect(copied).toBe(true); + expect(writeText).toHaveBeenCalledWith("hello"); + expect(fallbackWriteText).not.toHaveBeenCalled(); + }); + + it("falls back when the async Clipboard API is unavailable", async () => { + const fallbackWriteText = vi.fn(() => true); + + const copied = await writeClipboardText("hello", { isSecureContext: true, fallbackWriteText }); + + expect(copied).toBe(true); + expect(fallbackWriteText).toHaveBeenCalledWith("hello"); + }); + + it("falls back when the async Clipboard API rejects", async () => { + const writeText = vi.fn(() => Promise.reject(new Error("denied"))); + const fallbackWriteText = vi.fn(() => true); + + const copied = await writeClipboardText("hello", { isSecureContext: true, writeText, fallbackWriteText }); + + expect(copied).toBe(true); + expect(writeText).toHaveBeenCalledWith("hello"); + expect(fallbackWriteText).toHaveBeenCalledWith("hello"); + }); +}); diff --git a/src/client/src/clipboard.ts b/src/client/src/clipboard.ts new file mode 100644 index 0000000..1feccb2 --- /dev/null +++ b/src/client/src/clipboard.ts @@ -0,0 +1,101 @@ +export interface ClipboardTextWriteHost { + readonly isSecureContext: boolean; + readonly writeText?: (text: string) => Promise; + readonly fallbackWriteText: (text: string) => boolean; +} + +export async function writeClipboardText(text: string, host: ClipboardTextWriteHost = browserClipboardTextWriteHost()): Promise { + if (!host.isSecureContext) return host.fallbackWriteText(text); + + if (host.writeText !== undefined) { + try { + await host.writeText(text); + return true; + } catch { + return host.fallbackWriteText(text); + } + } + + return host.fallbackWriteText(text); +} + +function browserClipboardTextWriteHost(): ClipboardTextWriteHost { + const fallbackWriteText = (text: string) => writeClipboardTextWithSelectionFallback(text); + const writeText = browserClipboardWriteText(); + return writeText === undefined + ? { isSecureContext: browserIsSecureContext(), fallbackWriteText } + : { isSecureContext: browserIsSecureContext(), writeText, fallbackWriteText }; +} + +function browserIsSecureContext(): boolean { + return typeof window !== "undefined" && window.isSecureContext; +} + +function browserClipboardWriteText(): ((text: string) => Promise) | undefined { + if (typeof navigator === "undefined" || !("clipboard" in navigator)) return undefined; + return navigator.clipboard.writeText.bind(navigator.clipboard); +} + +function writeClipboardTextWithSelectionFallback(text: string): boolean { + if (typeof document === "undefined") return false; + + const activeElement = document.activeElement; + const selection = document.getSelection(); + const selectedRanges = selection === null ? [] : selectionRanges(selection); + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.readOnly = true; + textarea.setAttribute("aria-hidden", "true"); + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "-9999px"; + textarea.style.width = "1px"; + textarea.style.height = "1px"; + textarea.style.padding = "0"; + textarea.style.border = "0"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + + document.body.append(textarea); + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + try { + // eslint-disable-next-line @typescript-eslint/no-deprecated -- Required for HTTP/private-network pages where navigator.clipboard is unavailable. + return document.execCommand("copy"); + } catch { + return false; + } finally { + textarea.remove(); + restoreSelection(selection, selectedRanges); + restoreFocus(activeElement); + } +} + +function selectionRanges(selection: Selection): Range[] { + const ranges: Range[] = []; + for (let index = 0; index < selection.rangeCount; index += 1) { + ranges.push(selection.getRangeAt(index)); + } + return ranges; +} + +function restoreSelection(selection: Selection | null, ranges: readonly Range[]): void { + if (selection === null) return; + try { + selection.removeAllRanges(); + for (const range of ranges) selection.addRange(range); + } catch { + // Restoring the prior selection is best-effort; the copy result should remain authoritative. + } +} + +function restoreFocus(element: Element | null): void { + if (typeof HTMLElement === "undefined" || !(element instanceof HTMLElement)) return; + try { + element.focus({ preventScroll: true }); + } catch { + element.focus(); + } +} diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index d998c81..da47d3f 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -3,6 +3,7 @@ import { customElement, property, query, state } from "lit/decorators.js"; import { repeat } from "lit/directives/repeat.js"; import { ChatDisclosureController } from "../chatDisclosure"; import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGroups"; +import { writeClipboardText } from "../clipboard"; import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring"; import { shouldRequestEarlierMessages } from "../chatHistoryLoading"; import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition"; @@ -438,22 +439,14 @@ export class ChatView extends LitElement { private async copyMessage(message: ChatLine, key: string, event: MouseEvent): Promise { event.stopPropagation(); - const ok = await this.writeClipboard(this.messageCopyText(message)); - if (!ok) return; + const copied = await writeClipboardText(this.messageCopyText(message)); + if (!copied) return; this.copiedMessageKey = key; window.setTimeout(() => { if (this.copiedMessageKey === key) this.copiedMessageKey = undefined; }, 1200); } - private async writeClipboard(text: string): Promise { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - return false; - } - } private messageMetaLabel(message: ChatLine): { short: string; full: string } { const cached = this.messageMetaCache.get(message); diff --git a/src/client/src/components/FormattedText.ts b/src/client/src/components/FormattedText.ts index dccf74e..186341e 100644 --- a/src/client/src/components/FormattedText.ts +++ b/src/client/src/components/FormattedText.ts @@ -1,6 +1,7 @@ import { LitElement, html } from "lit"; import { customElement, property } from "lit/decorators.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { writeClipboardText } from "../clipboard"; import { toSafeMarkdownHtml } from "../formatting/markdown"; import { formattedTextStyles } from "./shared"; @@ -49,8 +50,8 @@ export class FormattedText extends LitElement { }; private async copyCode(text: string, button: HTMLButtonElement): Promise { - const ok = await writeClipboard(text); - this.setCopyButtonState(button, ok ? "copied" : "failed"); + const copied = await writeClipboardText(text); + this.setCopyButtonState(button, copied ? "copied" : "failed"); window.setTimeout(() => { this.setCopyButtonState(button, "idle"); }, 1200); @@ -67,11 +68,3 @@ export class FormattedText extends LitElement { static override styles = formattedTextStyles; } -async function writeClipboard(text: string): Promise { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - return false; - } -} diff --git a/src/client/src/components/ToolExecutionView.ts b/src/client/src/components/ToolExecutionView.ts index 4fd09a2..c031230 100644 --- a/src/client/src/components/ToolExecutionView.ts +++ b/src/client/src/components/ToolExecutionView.ts @@ -1,5 +1,6 @@ import { LitElement, css, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; +import { writeClipboardText } from "../clipboard"; import type { ToolExecutionPart } from "./shared"; const MAX_COLLAPSED_DIFF_LINES = 180; @@ -115,13 +116,13 @@ export class ToolExecutionView extends LitElement { } private async copyDiff(diff: string): Promise { - try { - await navigator.clipboard.writeText(diff); - this.copied = true; - window.setTimeout(() => { this.copied = false; }, 1200); - } catch { + const copied = await writeClipboardText(diff); + if (!copied) { this.copied = false; + return; } + this.copied = true; + window.setTimeout(() => { this.copied = false; }, 1200); } static override styles = css` From 3b2a225f87c01b8ebdbfbe8882555aad261f65bd Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Fri, 10 Jul 2026 19:44:31 +0000 Subject: [PATCH 073/111] fix(docker): resolve Pi runtime from peer dependency --- .changeset/docker-peer-pi-bin.md | 5 +++++ docker/Dockerfile | 15 +++++++++++---- docker/README.md | 17 +++++++---------- docker/compose.yml | 1 - docker/install.sh | 14 ++------------ docker/pi-web-docker | 2 +- src/server/dockerControlAssets.test.ts | 3 +++ 7 files changed, 29 insertions(+), 28 deletions(-) create mode 100644 .changeset/docker-peer-pi-bin.md diff --git a/.changeset/docker-peer-pi-bin.md b/.changeset/docker-peer-pi-bin.md new file mode 100644 index 0000000..5a3f96c --- /dev/null +++ b/.changeset/docker-peer-pi-bin.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Use PI WEB's npm peer dependency for the Docker Pi runtime and link the peer-provided `pi` binary instead of carrying a separate Pi package version setting. diff --git a/docker/Dockerfile b/docker/Dockerfile index 518d6d2..f14fa02 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -26,14 +26,21 @@ RUN chmod 0755 /usr/local/sbin/install-pi-web-opensuse-base \ FROM base AS package ARG PI_WEB_VERSION=latest -ARG PI_VERSION=latest ARG CACHE_BUST=local RUN set -eux; \ echo "PI WEB Docker build cache bust: ${CACHE_BUST}"; \ - npm install -g --omit=dev --no-audit --no-fund \ - "@jmfederico/pi-web@${PI_WEB_VERSION}" \ - "@earendil-works/pi-coding-agent@${PI_VERSION}"; \ + npm install -g --omit=dev --include=peer --no-audit --no-fund "@jmfederico/pi-web@${PI_WEB_VERSION}"; \ + global_root="$(npm root -g)"; \ + global_prefix="$(npm prefix -g)"; \ + peer_pi_bin="${global_root}/@jmfederico/pi-web/node_modules/.bin/pi"; \ + global_pi_bin="${global_prefix}/bin/pi"; \ + if [ -x "${peer_pi_bin}" ]; then \ + ln -sf "${peer_pi_bin}" "${global_pi_bin}"; \ + elif [ ! -x "${global_pi_bin}" ]; then \ + echo "Could not find pi binary from @earendil-works/pi-coding-agent" >&2; \ + exit 1; \ + fi; \ npm cache clean --force FROM base AS runtime diff --git a/docker/README.md b/docker/README.md index 8d89d97..a23d7cb 100644 --- a/docker/README.md +++ b/docker/README.md @@ -55,7 +55,7 @@ Defaults: - install directory: `~/.local/share/pi-web-docker` (or `$XDG_DATA_HOME/pi-web-docker`); - persistent data: `/data`, mounted at `/data`; - browser URL: ; -- npm packages: latest `@jmfederico/pi-web` and latest Pi Coding Agent package unless pinned. +- npm packages: latest `@jmfederico/pi-web`; Pi Coding Agent is resolved as PI WEB's npm peer dependency (newest compatible version) and the peer-provided `pi` binary is linked into the image. Updating recreates the Docker `sessiond` container. Active Pi agent runtimes in this Docker install may stop, so update while sessions are idle. Persisted PI WEB state, Pi config, and session history under the data directory are kept. @@ -93,8 +93,7 @@ curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/insta --data-dir ~/.local/share/pi-web-docker/data \ --bind-address 127.0.0.1 \ --port 8504 \ - --pi-web-version latest \ - --pi-version latest + --pi-web-version latest ``` Common environment variables written to `.env`: @@ -109,8 +108,7 @@ Common environment variables written to `.env`: | `PI_WEB_DOCKER_HOST_PROFILE`, `HOSTEXEC_MODE` | detected host profile and host-command capability toggle | | `PI_WEB_DOCKER_EXTRA_HOST_PATHS` | optional whitespace-separated existing absolute paths to bind-mount read/write at the same path | | `PI_WEB_BIND_ADDR`, `PI_WEB_PORT` | host bind address and port | -| `PI_WEB_VERSION` | npm version/range for `@jmfederico/pi-web` | -| `PI_VERSION` | npm version/range for `@earendil-works/pi-coding-agent` | +| `PI_WEB_VERSION` | npm version/range for `@jmfederico/pi-web`; Pi Coding Agent resolves from PI WEB's npm peer dependency | | `PI_WEB_OPENSUSE_IMAGE` | openSUSE base image used for the runtime build | | `PI_WEB_NODEJS_MAJOR` | Node.js major package to install, defaulting to `22` | | `PI_WEB_NODEJS_REPO` | Node.js zypper repository URL, `auto`, or `disabled` | @@ -119,7 +117,7 @@ Common environment variables written to `.env`: | `COMPOSE_PROJECT_NAME` | Docker Compose project name used by the runtime and its detached update/restart helpers; defaults to `pi-web` | | `HOSTEXEC_IMAGE` | helper image used by `hostexec` | -Host-derived IDs and the Docker host profile are refreshed on rerun unless you explicitly override the IDs. User-facing values such as data directory, bind address, port, image names, upload limit, extra host paths, base image, Node.js settings, extra packages, and version pins are preserved from an existing `.env` unless you pass a flag or environment override. +Host-derived IDs and the Docker host profile are refreshed on rerun unless you explicitly override the IDs. User-facing values such as data directory, bind address, port, image names, upload limit, extra host paths, base image, Node.js settings, extra packages, and npm package selection are preserved from an existing `.env` unless you pass a flag or environment override. The installer also writes a generated `compose.override.yml` in the install directory. `pi-web-docker` loads the generated `.env` and Compose override explicitly for runtime commands and passes the generated `COMPOSE_PROJECT_NAME` to Docker Compose, so an unrelated ambient Compose project name cannot redirect lifecycle commands. Re-run `pi-web-docker install` or `pi-web-docker update` instead of editing generated files by hand. @@ -173,21 +171,20 @@ Files in that development hook directory are ignored by Git except for the place ### Version pinning -Pin npm package versions when you want repeatable rebuilds: +Pi Coding Agent is resolved from PI WEB's npm peer dependency, and Docker links the peer-provided `pi` binary into `PATH`. Pin the PI WEB npm package when you want to stay on a specific PI WEB release: ```bash curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh \ - | sh -s -- --pi-web-version 1.202606.4 --pi-version 0.79.1 + | sh -s -- --pi-web-version 1.202606.4 ``` You can also edit `.env` in the install directory: ```dotenv PI_WEB_VERSION=1.202606.4 -PI_VERSION=0.79.1 ``` -Then rerun the one-liner to rebuild/recreate with those pins. Use `latest` again when you want the runtime to track the newest npm releases. +Then rerun the one-liner to rebuild/recreate with that pin. Use `PI_WEB_VERSION=latest` when you want the runtime to track the newest PI WEB release and the newest Pi package compatible with PI WEB's peer dependency range. To pin the Docker asset templates themselves, fetch the installer from a specific Git branch, tag, or commit and pass the same ref as the asset source: diff --git a/docker/compose.yml b/docker/compose.yml index 1d9a1b0..e619a17 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -11,7 +11,6 @@ x-pi-web-build: &pi-web-build PI_WEB_UID: ${PI_WEB_UID:-1000} PI_WEB_GID: ${PI_WEB_GID:-1000} PI_WEB_VERSION: ${PI_WEB_VERSION:-latest} - PI_VERSION: ${PI_VERSION:-latest} CACHE_BUST: ${CACHE_BUST:-local} x-pi-web-environment: &pi-web-environment diff --git a/docker/install.sh b/docker/install.sh index 8823893..4d98480 100755 --- a/docker/install.sh +++ b/docker/install.sh @@ -27,8 +27,6 @@ Options: --bind-address ADDR Host bind address (default: 127.0.0.1) --port PORT Host port (default: 8504) --pi-web-version VER npm @jmfederico/pi-web version pin (default: latest) - --pi-version VER npm @earendil-works/pi-coding-agent version pin - (default: latest) --opensuse-image IMAGE openSUSE base image (default: opensuse/tumbleweed) --nodejs-major MAJOR Node.js major version package to install (default: 22) --nodejs-repo REPO Node.js zypper repository URL, auto, or disabled @@ -49,7 +47,7 @@ Progressive host setup: Environment variables with the same names used in .env may also be set before running the installer, for example: - PI_WEB_VERSION=1.202606.4 PI_VERSION=0.79.1 docker/install.sh + PI_WEB_VERSION=1.202606.4 docker/install.sh EOF } @@ -80,11 +78,6 @@ while [ "$#" -gt 0 ]; do PI_WEB_VERSION=$2 shift 2 ;; - --pi-version) - [ "$#" -ge 2 ] || die "--pi-version requires a value" - PI_VERSION=$2 - shift 2 - ;; --opensuse-image) [ "$#" -ge 2 ] || die "--opensuse-image requires a value" PI_WEB_OPENSUSE_IMAGE=$2 @@ -372,7 +365,6 @@ data_dir=$(absolute_dir "$(path_from_base "$install_dir" "$raw_data_dir")") || d pi_web_bind_addr=$(value_from_env_or_existing_or_default PI_WEB_BIND_ADDR 127.0.0.1) pi_web_port=$(value_from_env_or_existing_or_default PI_WEB_PORT 8504) pi_web_version=$(value_from_env_or_existing_or_default PI_WEB_VERSION latest) -pi_version=$(value_from_env_or_existing_or_default PI_VERSION latest) pi_web_opensuse_image=$(value_from_env_or_existing_or_default PI_WEB_OPENSUSE_IMAGE opensuse/tumbleweed) pi_web_nodejs_major=$(value_from_env_or_existing_or_default PI_WEB_NODEJS_MAJOR 22) pi_web_nodejs_repo=$(value_from_env_or_existing_or_default PI_WEB_NODEJS_REPO auto) @@ -394,7 +386,6 @@ require_non_empty PI_WEB_DOCKER_REF "$asset_ref" require_non_empty PI_WEB_BIND_ADDR "$pi_web_bind_addr" require_non_empty PI_WEB_PORT "$pi_web_port" require_non_empty PI_WEB_VERSION "$pi_web_version" -require_non_empty PI_VERSION "$pi_version" require_non_empty PI_WEB_OPENSUSE_IMAGE "$pi_web_opensuse_image" require_non_empty PI_WEB_NODEJS_MAJOR "$pi_web_nodejs_major" require_non_empty PI_WEB_NODEJS_REPO "$pi_web_nodejs_repo" @@ -434,9 +425,8 @@ PI_WEB_DOCKER_REF=$asset_ref PI_WEB_BIND_ADDR=$pi_web_bind_addr PI_WEB_PORT=$pi_web_port -# npm version pins. Use latest for quick updates, or set concrete versions. +# npm package selection. Pi resolves from PI WEB's npm peer dependency. PI_WEB_VERSION=$pi_web_version -PI_VERSION=$pi_version # openSUSE/Node.js image build inputs. PI_WEB_OPENSUSE_IMAGE=$pi_web_opensuse_image diff --git a/docker/pi-web-docker b/docker/pi-web-docker index 2ecf8d4..8cf2498 100755 --- a/docker/pi-web-docker +++ b/docker/pi-web-docker @@ -626,7 +626,7 @@ start_detached_helper() { timestamp=$(date -u +%Y%m%d%H%M%S) helper_name=pi-web-docker-$action-$timestamp-$$ - generated_env_keys="PI_WEB_UID PI_WEB_GID DOCKER_GID PI_WEB_DOCKER_HOST_PROFILE HOSTEXEC_MODE PI_WEB_DOCKER_EXTRA_HOST_PATHS PI_WEB_DOCKER_DATA_DIR PI_WEB_DOCKER_INSTALL_DIR PI_WEB_DOCKER_DEV_REPO_ROOT PI_WEB_DOCKER_REF PI_WEB_BIND_ADDR PI_WEB_PORT PI_WEB_DEV_API_BIND_ADDR PI_WEB_DEV_BIND_ADDR PI_WEB_DEV_API_PORT PI_WEB_DEV_PORT PI_WEB_VERSION PI_VERSION PI_WEB_OPENSUSE_IMAGE PI_WEB_NODEJS_MAJOR PI_WEB_NODEJS_REPO PI_WEB_EXTRA_ZYPPER_PACKAGES PI_WEB_IMAGE PI_WEB_DEV_IMAGE COMPOSE_PROJECT_NAME HOSTEXEC_IMAGE PI_WEB_MAX_UPLOAD_BYTES" + generated_env_keys="PI_WEB_UID PI_WEB_GID DOCKER_GID PI_WEB_DOCKER_HOST_PROFILE HOSTEXEC_MODE PI_WEB_DOCKER_EXTRA_HOST_PATHS PI_WEB_DOCKER_DATA_DIR PI_WEB_DOCKER_INSTALL_DIR PI_WEB_DOCKER_DEV_REPO_ROOT PI_WEB_DOCKER_REF PI_WEB_BIND_ADDR PI_WEB_PORT PI_WEB_DEV_API_BIND_ADDR PI_WEB_DEV_BIND_ADDR PI_WEB_DEV_API_PORT PI_WEB_DEV_PORT PI_WEB_VERSION PI_WEB_OPENSUSE_IMAGE PI_WEB_NODEJS_MAJOR PI_WEB_NODEJS_REPO PI_WEB_EXTRA_ZYPPER_PACKAGES PI_WEB_IMAGE PI_WEB_DEV_IMAGE COMPOSE_PROJECT_NAME HOSTEXEC_IMAGE PI_WEB_MAX_UPLOAD_BYTES" set -- run -d \ --env-file "$env_file" \ diff --git a/src/server/dockerControlAssets.test.ts b/src/server/dockerControlAssets.test.ts index 35f1965..96ebbc2 100644 --- a/src/server/dockerControlAssets.test.ts +++ b/src/server/dockerControlAssets.test.ts @@ -57,6 +57,9 @@ describe("Docker command assets", () => { expect(dockerfile).toContain("COPY pi-web-docker /usr/local/bin/pi-web-docker"); expect(dockerfile).toContain("COPY internal/bin/hostexec /usr/local/bin/hostexec"); expect(dockerfile).toContain("COPY internal/image/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base"); + expect(dockerfile).toContain("--include=peer"); + expect(dockerfile).toContain('peer_pi_bin="${global_root}/@jmfederico/pi-web/node_modules/.bin/pi"'); + expect(dockerfile).not.toContain("@earendil-works/pi-coding-agent@"); expect(devDockerfile).toContain("COPY docker/pi-web-docker /usr/local/bin/pi-web-docker"); expect(devDockerfile).toContain("COPY docker/internal/bin/hostexec /usr/local/bin/hostexec"); expect(dockerignore).toContain("!pi-web-docker"); From abcf44b962cfe9a45bc3673150896b4a7cb6553e Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 10 Jul 2026 22:08:12 +0200 Subject: [PATCH 074/111] fix: show complete chat message metadata --- .changeset/show-complete-message-metadata.md | 5 ++ src/client/src/components/ChatView.test.ts | 15 +++++- src/client/src/components/ChatView.ts | 57 ++++++++++---------- src/client/src/components/shared.ts | 12 +++-- 4 files changed, 53 insertions(+), 36 deletions(-) create mode 100644 .changeset/show-complete-message-metadata.md diff --git a/.changeset/show-complete-message-metadata.md b/.changeset/show-complete-message-metadata.md new file mode 100644 index 0000000..2eb3305 --- /dev/null +++ b/.changeset/show-complete-message-metadata.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Show complete chat message dates and model identifiers in one consistent label, wrap rather than truncate expanded metadata, and let the clean touch info control collapse while it retains focus. diff --git a/src/client/src/components/ChatView.test.ts b/src/client/src/components/ChatView.test.ts index 4322c54..3aa8484 100644 --- a/src/client/src/components/ChatView.test.ts +++ b/src/client/src/components/ChatView.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { chatQueuedMessageSections } from "./ChatView"; +import { chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView"; describe("chatQueuedMessageSections", () => { it("labels client-side pending-start sends separately from server queued messages", () => { @@ -22,3 +22,16 @@ describe("chatQueuedMessageSections", () => { ]); }); }); + +describe("chatMessageMetadataLabel", () => { + it("uses one full date and model label without a model prefix", () => { + const timestamp = "2026-07-10T19:15:30.000Z"; + const formattedTimestamp = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }).format(new Date(timestamp)); + + expect(chatMessageMetadataLabel({ + role: "assistant", + parts: [], + meta: { timestamp, model: { provider: "provider", id: "model" } }, + })).toBe(`${formattedTimestamp} · provider/model`); + }); +}); diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index da47d3f..0e67e7d 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -14,8 +14,7 @@ import "./ConversationMeter"; import "./FormattedText"; import "./ToolExecutionView"; -const shortTimestampFormatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); -const fullTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }); +const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }); const partialStreamNoticeBodies = [ "You opened this chat while the assistant was already replying. The complete answer will appear shortly.", @@ -52,6 +51,28 @@ export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[], ].filter((section): section is QueuedMessageSection => section !== undefined); } +export function chatMessageMetadataLabel(message: ChatLine): string { + const timestamp = message.meta?.timestamp; + const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp); + const model = chatMessageModelLabel(message); + const parts = [time, model].filter((part): part is string => part !== undefined && part !== ""); + return parts.length === 0 ? "No Pi message metadata available" : parts.join(" · "); +} + +function formatMessageTimestamp(timestamp: string): string | undefined { + const date = new Date(timestamp); + if (!Number.isFinite(date.getTime())) return undefined; + return messageTimestampFormatter.format(date); +} + +function chatMessageModelLabel(message: ChatLine): string | undefined { + const model = message.meta?.model; + if (model === undefined) return undefined; + const id = model.responseId ?? model.id; + if (id === undefined || id === "") return model.provider; + return model.provider !== undefined && model.provider !== "" ? `${model.provider}/${id}` : id; +} + @customElement("chat-view") export class ChatView extends LitElement { @property({ attribute: false }) messages: ChatLine[] = []; @@ -84,7 +105,7 @@ export class ChatView extends LitElement { private groupedMessagesInput?: ChatLine[]; private groupedMessagesStart = 0; private groupedMessagesCache: ChatGroup[] = []; - private readonly messageMetaCache = new WeakMap(); + private readonly messageMetaCache = new WeakMap(); private readonly messageCopyTextCache = new WeakMap(); private partialStreamNoticeBody: string | undefined; private lastScrollTop = 0; @@ -397,7 +418,7 @@ export class ChatView extends LitElement { ${message.role}
    ${this.renderMessageActions(message, key)} - { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta.short} + { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta}
    `; @@ -448,38 +469,14 @@ export class ChatView extends LitElement { } - private messageMetaLabel(message: ChatLine): { short: string; full: string } { + private messageMetaLabel(message: ChatLine): string { const cached = this.messageMetaCache.get(message); if (cached !== undefined) return cached; - const timestamp = message.meta?.timestamp; - const model = this.modelLabel(message); - if (timestamp === undefined && model === undefined) { - const empty = { short: "no info", full: "No Pi message metadata available" }; - this.messageMetaCache.set(message, empty); - return empty; - } - const time = timestamp === undefined ? undefined : this.formatTimestamp(timestamp); - const parts = [time?.short, model].filter((part): part is string => part !== undefined && part !== ""); - const fullParts = [time?.full, model === undefined ? undefined : `Model: ${model}`].filter((part): part is string => part !== undefined && part !== ""); - const label = { short: parts.join(" · "), full: fullParts.join(" · ") }; + const label = chatMessageMetadataLabel(message); this.messageMetaCache.set(message, label); return label; } - private formatTimestamp(timestamp: string): { short: string; full: string } | undefined { - const date = new Date(timestamp); - if (!Number.isFinite(date.getTime())) return undefined; - return { short: shortTimestampFormatter.format(date), full: fullTimestampFormatter.format(date) }; - } - - private modelLabel(message: ChatLine): string | undefined { - const model = message.meta?.model; - if (model === undefined) return undefined; - const id = model.responseId ?? model.id; - if (id === undefined || id === "") return model.provider; - return model.provider !== undefined && model.provider !== "" ? `${model.provider}/${id}` : id; - } - private renderPart(part: ChatPart, message?: ChatLine) { if (part.type === "text" && message?.role === "bash") return html`
    ${part.text}
    `; if (part.type === "text") return html``; diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 4422620..b0b4045 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -327,22 +327,24 @@ export const chatStyles = css` .msg.bash > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-success) 35%, transparent); background: var(--pi-success-bg); } .msg.skill > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-purple-border) 35%, transparent); background: var(--pi-purple-surface); } .group-msg > .msg-header { position: sticky; top: -26px; z-index: 4; margin: -10px 0 8px; padding: 7px 0 6px; border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent); background: var(--pi-bg); } - .msg-header-trailing { min-width: 0; display: inline-flex; align-items: baseline; justify-content: flex-end; gap: 8px; } - .msg-actions { display: inline-flex; gap: 6px; opacity: 0; transition: opacity .12s ease; } + .msg-header-trailing { min-width: 0; flex: 1 1 auto; display: inline-flex; align-items: baseline; justify-content: flex-end; gap: 8px; } + .msg-actions { flex: 0 0 auto; display: inline-flex; gap: 6px; opacity: 0; transition: opacity .12s ease; } .msg-action { display: inline-grid; place-items: center; width: 24px; height: 24px; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 0; font: 14px system-ui, sans-serif; line-height: 1; cursor: pointer; } .msg-action:hover, .msg-action:focus { color: var(--pi-text); border-color: var(--pi-accent); } .msg:hover > .msg-header .msg-actions, .msg:focus-within > .msg-header .msg-actions, .group-msg:hover > .msg-header .msg-actions, .group-msg:focus-within > .msg-header .msg-actions { opacity: 1; } .label { display: block; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } .msg-header .label { margin: 0; } - .msg-meta { min-width: 0; opacity: .28; border: 0; background: transparent; color: var(--pi-dim); padding: 0; font: 11px system-ui, sans-serif; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity .12s ease, max-width .12s ease; cursor: pointer; user-select: text; -webkit-user-select: text; } + .msg-meta { min-width: 0; opacity: .28; border: 0; background: transparent; color: var(--pi-dim); padding: 0; font: 11px system-ui, sans-serif; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity .12s ease; cursor: pointer; user-select: text; -webkit-user-select: text; } .msg:hover > .msg-header .msg-meta, .msg:focus-within > .msg-header .msg-meta, .group-msg:hover > .msg-header .msg-meta, .group-msg:focus-within > .msg-header .msg-meta, .msg-meta:focus, .msg-meta.expanded { opacity: 1; } + .msg-meta.expanded { flex: 1 1 auto; max-width: 100%; white-space: normal; overflow: visible; overflow-wrap: anywhere; text-overflow: clip; } .msg-meta:focus { outline: 1px solid var(--pi-border); outline-offset: 3px; border-radius: 4px; } @media (hover: none) { .msg-actions { opacity: 1; } .msg-meta { opacity: .75; max-width: 26px; } + .msg-meta:not(.expanded) { display: inline-grid; width: 26px; height: 26px; place-items: center; font-size: 0; text-overflow: clip; } .msg-meta::before { content: "ⓘ"; font-size: 13px; } - .msg-meta:focus, .msg-meta.expanded { opacity: 1; max-width: 75%; } - .msg-meta:focus::before, .msg-meta.expanded::before { content: ""; } + .msg-meta.expanded { opacity: 1; max-width: 100%; } + .msg-meta.expanded::before { content: ""; } } formatted-text.part { display: block; } formatted-text.part, .queued-message formatted-text { text-align: start; unicode-bidi: plaintext; } From 32907bba5f6d29eabba4e26901b67fc84e8a8f9f Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 10 Jul 2026 22:09:35 +0200 Subject: [PATCH 075/111] chore(deps): update runtime and development packages --- .changeset/refresh-dependencies.md | 5 + package-lock.json | 2380 ++++++++--------- package.json | 50 +- .../src/components/selectableRow.test.ts | 5 +- src/client/src/vite-env.d.ts | 1 + src/server/sessions/piSessionService.test.ts | 9 +- src/shared/thinkingLevels.test.ts | 13 +- src/shared/thinkingLevels.ts | 2 +- tsconfig.json | 1 - 9 files changed, 1140 insertions(+), 1326 deletions(-) create mode 100644 .changeset/refresh-dependencies.md create mode 100644 src/client/src/vite-env.d.ts diff --git a/.changeset/refresh-dependencies.md b/.changeset/refresh-dependencies.md new file mode 100644 index 0000000..9143754 --- /dev/null +++ b/.changeset/refresh-dependencies.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Update runtime and development dependencies, including Pi 0.80.6 support and the `max` thinking level. diff --git a/package-lock.json b/package-lock.json index 90a46c5..a30b33c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.202606.7", "license": "MIT", "dependencies": { - "@codemirror/commands": "^6.10.3", + "@codemirror/commands": "^6.10.4", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-go": "^6.0.1", "@codemirror/lang-html": "^6.4.11", @@ -18,21 +18,21 @@ "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-rust": "^6.0.2", - "@codemirror/language": "^6.12.3", - "@codemirror/legacy-modes": "^6.5.2", - "@codemirror/state": "^6.6.0", - "@codemirror/view": "^6.42.1", - "@fastify/static": "^9.1.3", - "@fastify/websocket": "^11.2.0", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.3", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.6", + "@fastify/static": "^9.3.0", + "@fastify/websocket": "^11.3.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "diff": "^8.0.4", - "fastify": "^5.6.1", - "lit": "^3.3.1", - "marked": "^18.0.3", + "diff": "^9.0.0", + "fastify": "^5.10.0", + "lit": "^3.3.3", + "marked": "^18.0.6", "node-pty": "^1.1.0", - "typebox": "1.1.38", - "ws": "^8.20.1" + "typebox": "1.3.6", + "ws": "^8.21.0" }, "bin": { "pi-web": "dist/cli.js", @@ -41,20 +41,20 @@ }, "devDependencies": { "@changesets/cli": "^2.31.0", - "@earendil-works/pi-agent-core": "^0.80.3", - "@earendil-works/pi-ai": "^0.80.3", - "@earendil-works/pi-coding-agent": "^0.80.3", + "@earendil-works/pi-agent-core": "^0.80.6", + "@earendil-works/pi-ai": "^0.80.6", + "@earendil-works/pi-coding-agent": "^0.80.6", "@eslint/js": "^10.0.1", - "@types/node": "^24.10.1", + "@types/node": "^24.13.3", "@types/ws": "^8.18.1", - "eslint": "^10.3.0", - "globals": "^17.6.0", - "knip": "^6.16.1", - "tsx": "^4.20.6", - "typescript": "^5.9.3", - "typescript-eslint": "^8.59.2", - "vite": "^7.2.4", - "vitest": "^4.1.5" + "eslint": "^10.6.0", + "globals": "^17.7.0", + "knip": "^6.25.0", + "tsx": "^4.23.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.63.0", + "vite": "^8.1.4", + "vitest": "^4.1.10" }, "engines": { "node": ">=22" @@ -166,18 +166,18 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.26", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.26.tgz", - "integrity": "sha512-wRj7Pthvjk3anees97pUWlxlTa0DUjeGrEQU5fKDZVdWZV0ekaprbof0df2uaE9g8u67t035v2j+ne2AW2UMkA==", + "version": "3.975.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.1.tgz", + "integrity": "sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.15", - "@aws-sdk/xml-builder": "^3.972.33", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.29.0", - "@smithy/signature-v4": "^5.6.1", - "@smithy/types": "^4.15.1", + "@aws-sdk/types": "^3.974.0", + "@aws-sdk/xml-builder": "^3.972.34", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.2", + "@smithy/signature-v4": "^5.6.3", + "@smithy/types": "^4.16.0", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -186,16 +186,16 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.52", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.52.tgz", - "integrity": "sha512-sxuaHZGHqOgKB8OdL3doXa1NJjqmO60FPfyTnYVKGjX9taRsIEGS9pd+2yALmo06hijZ8L94uSK0kfXZsRmVyA==", + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.57.tgz", + "integrity": "sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.26", - "@aws-sdk/types": "^3.973.15", - "@smithy/core": "^3.29.0", - "@smithy/types": "^4.15.1", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -203,18 +203,18 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.54", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.54.tgz", - "integrity": "sha512-e6yz52nq3SpR1oPLcvfsDM7H7k2gIYk/NSn/rwsFqzGXEwr3g0mRMlPbLaKCPCGNZJMU/gZg6/64B3eSam+gBw==", + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.59.tgz", + "integrity": "sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.26", - "@aws-sdk/types": "^3.973.15", - "@smithy/core": "^3.29.0", - "@smithy/fetch-http-handler": "^5.6.2", - "@smithy/node-http-handler": "^4.9.2", - "@smithy/types": "^4.15.1", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -222,14 +222,14 @@ } }, "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.2.tgz", - "integrity": "sha512-s0yAIRj6TVfHgl+QzVyqal1KMGZ9B5512IrxKc6+dOpw8fUmFL3CvuAhjv0J+aNjUPfVZ2IhqPEDvkB5Ncx9oA==", + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.4.tgz", + "integrity": "sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.0", - "@smithy/types": "^4.15.1", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -237,24 +237,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.59", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.59.tgz", - "integrity": "sha512-9Um/UpruN76AdpiLnvwChVkJJwJ9Vx9ykk/2AeLxxSCM/YYRD8Kkq2towUk9fZQLV7dd9ATlsi87U7hKs0z/iQ==", + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.1.tgz", + "integrity": "sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.26", - "@aws-sdk/credential-provider-env": "^3.972.52", - "@aws-sdk/credential-provider-http": "^3.972.54", - "@aws-sdk/credential-provider-login": "^3.972.58", - "@aws-sdk/credential-provider-process": "^3.972.52", - "@aws-sdk/credential-provider-sso": "^3.972.58", - "@aws-sdk/credential-provider-web-identity": "^3.972.58", - "@aws-sdk/nested-clients": "^3.997.26", - "@aws-sdk/types": "^3.973.15", - "@smithy/core": "^3.29.0", - "@smithy/credential-provider-imds": "^4.4.5", - "@smithy/types": "^4.15.1", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-env": "^3.972.57", + "@aws-sdk/credential-provider-http": "^3.972.59", + "@aws-sdk/credential-provider-login": "^3.972.63", + "@aws-sdk/credential-provider-process": "^3.972.57", + "@aws-sdk/credential-provider-sso": "^3.973.1", + "@aws-sdk/credential-provider-web-identity": "^3.972.63", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/credential-provider-imds": "^4.4.7", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -262,17 +262,17 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.58", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.58.tgz", - "integrity": "sha512-H3q96qF8/DJsPsXMVtMRqSWOc85K5O4zos32untdw+vE5vw0f3a6qJo1YqbND4BsEIKd4iZmzzVUq9kV4LjbHg==", + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.63.tgz", + "integrity": "sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.26", - "@aws-sdk/nested-clients": "^3.997.26", - "@aws-sdk/types": "^3.973.15", - "@smithy/core": "^3.29.0", - "@smithy/types": "^4.15.1", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -280,22 +280,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.61", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.61.tgz", - "integrity": "sha512-2U2KHMRCt1dlZoLU3KZR5g5EL4b0h2HHw96SkaUBK7qvEXPZj5rGRO/3ZTeJmh37dIYQuCnA2273rZOQvmsiHw==", + "version": "3.972.66", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.66.tgz", + "integrity": "sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.52", - "@aws-sdk/credential-provider-http": "^3.972.54", - "@aws-sdk/credential-provider-ini": "^3.972.59", - "@aws-sdk/credential-provider-process": "^3.972.52", - "@aws-sdk/credential-provider-sso": "^3.972.58", - "@aws-sdk/credential-provider-web-identity": "^3.972.58", - "@aws-sdk/types": "^3.973.15", - "@smithy/core": "^3.29.0", - "@smithy/credential-provider-imds": "^4.4.5", - "@smithy/types": "^4.15.1", + "@aws-sdk/credential-provider-env": "^3.972.57", + "@aws-sdk/credential-provider-http": "^3.972.59", + "@aws-sdk/credential-provider-ini": "^3.973.1", + "@aws-sdk/credential-provider-process": "^3.972.57", + "@aws-sdk/credential-provider-sso": "^3.973.1", + "@aws-sdk/credential-provider-web-identity": "^3.972.63", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/credential-provider-imds": "^4.4.7", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -303,16 +303,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.52", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.52.tgz", - "integrity": "sha512-Aff9Ebs42lz+Ep1wkS+Nlwh5S0eahakpyskPsuKGjiBJ6ExOjNtxbfKJTKovQtQNgJ7oG1BH6esJwGrbs7qgSA==", + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.57.tgz", + "integrity": "sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.26", - "@aws-sdk/types": "^3.973.15", - "@smithy/core": "^3.29.0", - "@smithy/types": "^4.15.1", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -320,18 +320,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.58", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.58.tgz", - "integrity": "sha512-syloC58mXOacUqM2toPNfwd7X3jT+tWj0F/cN7qdW1FQyI0q41J0tPf6DIZ56BF0x82iS9j3ALP45MoBz79YuQ==", + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.1.tgz", + "integrity": "sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.26", - "@aws-sdk/nested-clients": "^3.997.26", - "@aws-sdk/token-providers": "3.1078.0", - "@aws-sdk/types": "^3.973.15", - "@smithy/core": "^3.29.0", - "@smithy/types": "^4.15.1", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/token-providers": "3.1083.0", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -339,17 +339,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { - "version": "3.1078.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1078.0.tgz", - "integrity": "sha512-/uyXLBGu3Lw1GbBA2X66hcOMnKtMcqAIF+3/eHfxBQmUeXF2sdqozDPrTfEr/TnSd0D6deZar+eVyhEqqWu29w==", + "version": "3.1083.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1083.0.tgz", + "integrity": "sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.26", - "@aws-sdk/nested-clients": "^3.997.26", - "@aws-sdk/types": "^3.973.15", - "@smithy/core": "^3.29.0", - "@smithy/types": "^4.15.1", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -357,17 +357,17 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.58", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.58.tgz", - "integrity": "sha512-pTBImKzcGK+pcMKjL0fAJbnYzzYd1c0UDc7BSIOGNQhF9Nuk66vWlIXfYTYyzNSs+w8Q/vfbbNDDU8zdrouwLg==", + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.63.tgz", + "integrity": "sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.26", - "@aws-sdk/nested-clients": "^3.997.26", - "@aws-sdk/types": "^3.973.15", - "@smithy/core": "^3.29.0", - "@smithy/types": "^4.15.1", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -375,15 +375,15 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.25.tgz", - "integrity": "sha512-df7HN1ozwMrB9+59re9PM7tSLxLAcheMWc5u/KyfCPCAWtN/vP7y7RTUZOy48uT1K9MESisVeOPPzF3O1AW01A==", + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.26.tgz", + "integrity": "sha512-RE1fu7Nn05vG0EUJM+8Sde2GFecC658WGaC/asPzLF6K4x3H5ZaDBcQtHRE67Gdgb1VZpyUUliYejHFK1qt0Uw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.15", - "@smithy/core": "^3.29.0", - "@smithy/types": "^4.15.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -391,15 +391,15 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.21.tgz", - "integrity": "sha512-HvLgDnxBLaHi9E5K++6Vuk+1+qqn7Pmn8zrlzd+NXH3jBzwujnuzZtAR9WHPkbUGPO92FkoQWj/M1IsdxTlBmQ==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.22.tgz", + "integrity": "sha512-jtkgmhevnpzC1WeS+Y/sgymYbaQ6qg7pVOUl5cUT/8MiLptqrtnXQlNV80m+j2WIx5MIL7kVHIZNxxcK2tfUEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.15", - "@smithy/core": "^3.29.0", - "@smithy/types": "^4.15.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -407,18 +407,18 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.34.tgz", - "integrity": "sha512-8dxKLu5bC74SLwwoYV8RIiCD48jMbMt1Ccl3m+xtQJKet6QsZ4xzJlK6UDg7QNEzm/ZCUknJfGsBHmhkgOfuIQ==", + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.39.tgz", + "integrity": "sha512-CS1spxRSezmTmI3PD+3Xrnp6KryTSEz0EefA8u6uGd0s2I0uXseWHALDI/03Wi0IUczXNWo2QrZEaHDuJNby/Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.26", - "@aws-sdk/types": "^3.973.15", - "@smithy/core": "^3.29.0", - "@smithy/fetch-http-handler": "^5.6.2", - "@smithy/signature-v4": "^5.6.1", - "@smithy/types": "^4.15.1", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/signature-v4": "^5.6.3", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -426,19 +426,19 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.26", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.26.tgz", - "integrity": "sha512-Lwe3F6K7bs+jEubp1LbrvzeMBYb5fMazJ1IxV9TtKWPF8CSh67Fmwyq9fLz3NL/k55Dfpuph5Dimw76JFgr+SA==", + "version": "3.997.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.31.tgz", + "integrity": "sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.26", - "@aws-sdk/signature-v4-multi-region": "^3.996.38", - "@aws-sdk/types": "^3.973.15", - "@smithy/core": "^3.29.0", - "@smithy/fetch-http-handler": "^5.6.2", - "@smithy/node-http-handler": "^4.9.2", - "@smithy/types": "^4.15.1", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.39", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -446,14 +446,14 @@ } }, "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.2.tgz", - "integrity": "sha512-s0yAIRj6TVfHgl+QzVyqal1KMGZ9B5512IrxKc6+dOpw8fUmFL3CvuAhjv0J+aNjUPfVZ2IhqPEDvkB5Ncx9oA==", + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.4.tgz", + "integrity": "sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.0", - "@smithy/types": "^4.15.1", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -461,15 +461,15 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz", - "integrity": "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==", + "version": "3.996.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.39.tgz", + "integrity": "sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.15", - "@smithy/signature-v4": "^5.6.1", - "@smithy/types": "^4.15.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/signature-v4": "^5.6.3", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -495,13 +495,13 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.15.tgz", - "integrity": "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==", + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", + "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.15.1", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -522,13 +522,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.33", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.33.tgz", - "integrity": "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.34.tgz", + "integrity": "sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.15.1", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -536,9 +536,9 @@ } }, "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -810,13 +810,13 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.10.3", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", - "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.6.0", + "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } @@ -928,9 +928,9 @@ } }, "node_modules/@codemirror/language": { - "version": "6.12.3", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", - "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -962,34 +962,34 @@ } }, "node_modules/@codemirror/state": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", - "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "node_modules/@codemirror/view": { - "version": "6.43.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz", - "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==", + "version": "6.43.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", + "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", "license": "MIT", "dependencies": { - "@codemirror/state": "^6.6.0", + "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.3.tgz", - "integrity": "sha512-3qw0/GeRQBU/nlGjDe5Yb7ePKTmoxefx2YxyKMFAviFUMXpFexBG/hS7mBtwFahFvzrrTPPoRT6sFIDjwoDWPQ==", + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz", + "integrity": "sha512-Lvn89ko42h5ETUb6Z0Ku6ldskEqXaTdQBYvSa0+7bdG9V6rUEpXptv5e0OVZ1HDcvi8s6/2lGCQWsxKX+DFHNw==", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.3", + "@earendil-works/pi-ai": "^0.80.6", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -998,10 +998,17 @@ "node": ">=22.19.0" } }, + "node_modules/@earendil-works/pi-agent-core/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "dev": true, + "license": "MIT" + }, "node_modules/@earendil-works/pi-ai": { - "version": "0.80.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.3.tgz", - "integrity": "sha512-jPZLMeGL5kkMSEAwAklfXTMHqZvfhsJtCCpKGIr5Duk7mc0n4skjB1dugk7y0z3z8ZHIUCmPAWHdyDqgUz5vdA==", + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz", + "integrity": "sha512-7xfLk8sANBp+bpPEbjoOZTbPxsa+++b1JXAoSJsNa3vbs9AHHEclmvg54XLQcxH+fuwaeti/g2jeIfJ+mVYLpA==", "dev": true, "license": "MIT", "dependencies": { @@ -1024,17 +1031,23 @@ "node": ">=22.19.0" } }, + "node_modules/@earendil-works/pi-ai/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "dev": true, + "license": "MIT" + }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.80.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.3.tgz", - "integrity": "sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==", + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.6.tgz", + "integrity": "sha512-vcfD6tOk402isLl3Cm/qbn2O10TvgroMp1+/fEGM24ZdvETFCdOYv5VZ7m59EI5fPsjfSJh+CpQ5bhBrhfOg7g==", "dev": true, - "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.3", - "@earendil-works/pi-ai": "^0.80.3", - "@earendil-works/pi-tui": "^0.80.3", + "@earendil-works/pi-agent-core": "^0.80.6", + "@earendil-works/pi-ai": "^0.80.6", + "@earendil-works/pi-tui": "^0.80.6", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -1524,12 +1537,12 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.3.tgz", + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.3", + "@earendil-works/pi-ai": "^0.80.6", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -1539,8 +1552,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.80.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.3.tgz", + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -1557,15 +1570,15 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.80.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.3.tgz", + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.6.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -2062,16 +2075,6 @@ "node": ">=14.0.0" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2890,13 +2893,6 @@ "node": ">=22.19.0" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -2998,21 +2994,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -3021,9 +3017,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -3774,9 +3770,9 @@ } }, "node_modules/@fastify/static": { - "version": "9.1.3", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz", - "integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.3.0.tgz", + "integrity": "sha512-9YMYRpCOtMBrqKYWcqiw7ykOrn4D0jogHpJrFS0KGeSuOwzKMM5/mjj7B0CFLVoQ6htqKYw//Zs7APn9DBq05w==", "funding": [ { "type": "github", @@ -3792,15 +3788,15 @@ "@fastify/accept-negotiator": "^2.0.0", "@fastify/send": "^4.0.0", "content-disposition": "^1.0.1", - "fastify-plugin": "^5.0.0", + "fastify-plugin": "^6.0.0", "fastq": "^1.17.1", "glob": "^13.0.0" } }, "node_modules/@fastify/websocket": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz", - "integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.0.tgz", + "integrity": "sha512-g89ag4BCcD9YP5wBZXixzoLnuf5j89p/sXFcfpCiv2pdEkYYukBEoK3heVzqsp0EAtszVDc2BBZG0KZqeAShIA==", "funding": [ { "type": "github", @@ -3814,7 +3810,7 @@ "license": "MIT", "dependencies": { "duplexify": "^4.1.3", - "fastify-plugin": "^5.0.0", + "fastify-plugin": "^6.0.0", "ws": "^8.16.0" } }, @@ -4173,14 +4169,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -4240,9 +4236,9 @@ } }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", - "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4250,9 +4246,9 @@ } }, "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.135.0.tgz", - "integrity": "sha512-sHeZItACNcA5WRAWqF6ixriR4GkZDyY10gVgnZU7pXku1DjHFATSqnwZM809jl0gXPHxb6fKzYQCK7bNK5cACQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.137.0.tgz", + "integrity": "sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA==", "cpu": [ "arm" ], @@ -4267,9 +4263,9 @@ } }, "node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.135.0.tgz", - "integrity": "sha512-wPte+SzgzWWFgMSF8YZDNM+tBXtJg0AXBi7+tU3yS2z1f2Af9kRLZLKuJojADmuD/cZexmnMHHC3SDItTW77Iw==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.137.0.tgz", + "integrity": "sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw==", "cpu": [ "arm64" ], @@ -4284,9 +4280,9 @@ } }, "node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.135.0.tgz", - "integrity": "sha512-BmKz3lHIsqVos+9aPcdYCT9MG3APoUyM43KlEFhJMWNVDOGG8FKyiFz81Bc+mGz2o0hpuQ3PfXLfVWJrKXjo2g==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.137.0.tgz", + "integrity": "sha512-bFPr5hgmNMOMoyPTGtdsK4Ug21RovIPojRMgDDhSp1LtCnc/DkLwGONKjgRjszg677RlGnkYSviQ8hHaUPOVYA==", "cpu": [ "arm64" ], @@ -4301,9 +4297,9 @@ } }, "node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.135.0.tgz", - "integrity": "sha512-dM8BS+8+Br1fNvmh2QZbGiHaYttwLebRa6J4Uz9vuFzMNmvsdRYwf7993ptOaV0JTrR63AaoVLjX7nhWbijxjQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.137.0.tgz", + "integrity": "sha512-CL5dMm1asqXIDZHg14FLxj3Mc36w8PI7xCWh1uA4is6z8g2XrIILoTcQYOxDbwzuk34RDPX5IAGUxZr6LA9KAg==", "cpu": [ "x64" ], @@ -4318,9 +4314,9 @@ } }, "node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.135.0.tgz", - "integrity": "sha512-xlZnvvJdR9bGu2pOhvR5hMuKPHCE6Sa9owK5A484mzjHdm75VRV5nCs5w/jkmGODMMTFc+KN7EnZqEieM813kw==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.137.0.tgz", + "integrity": "sha512-79h8rYGnSlKPGWo7mHr2ixO6ea7aW8B0CT965SZ8SLbNnCOH5aOYBTeVXUY6eMvEaiLyWr8Skuiugr5pDYgLGw==", "cpu": [ "x64" ], @@ -4335,9 +4331,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.135.0.tgz", - "integrity": "sha512-PSR8LmBK/H/PQRiN8g7RebQgZX/ntVCrdT/JBfNxE5ezdHG1s2i4rbazsRJYD83TTI1MmgTpC0MGL42PLtskQQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.137.0.tgz", + "integrity": "sha512-ASgmlSimhGyr0lksgVIo6hibz1obnDq4qJbiMX/AzltfgPnanRrzG1Q+23g8ljOHOjv6dsznkUuCYL3gg0sY1Q==", "cpu": [ "arm" ], @@ -4352,9 +4348,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.135.0.tgz", - "integrity": "sha512-I85GJXzfUsigkkk7Ngdz95C217M4FdUi1Z2HrX5UyPmURobwQZ7m2bbUvwFkz4VGZd+lymFGKHvDZ3RQC9qOzA==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.137.0.tgz", + "integrity": "sha512-AU2J9aa22Sx32wRGnDjybOU9TQXXQUud5sdUi+ZB0XxwM8aToWLweV+yA0wlQm0yIUVqljquqoHCYEq9II8gJQ==", "cpu": [ "arm" ], @@ -4369,13 +4365,16 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.135.0.tgz", - "integrity": "sha512-zqEY0npz0g0aGZj/8a5BclunjVDytsBQHYtIC10Gd26HcrLwbVF6YDbqRQjunMGYdSo97u6xOBl05aTDI2diDQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.137.0.tgz", + "integrity": "sha512-GdEtiG89yMr7XkUGxifgodXEEm2f+xW2f9CpDjlgAnBOwhTmrpQMvhOGobLVKUyzf/qHBXW16smk5zbF3nZU6w==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4386,13 +4385,16 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.135.0.tgz", - "integrity": "sha512-mWAfprP819gQ2qYst1RxgTI8b/z0b29OpoKfRflIXLHde2dZLihQD4g47Onuvtpo5GPIkMYPRlX9QoeZfs/GnQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.137.0.tgz", + "integrity": "sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4403,13 +4405,16 @@ } }, "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.135.0.tgz", - "integrity": "sha512-gri8c2AOmJKJwOux2KTHFBfUaXoJURuVMKhmKEi/2hTF55cQteTDV2XNfTiE5oCC+Tnem1Y4/MWzcyDadtsSag==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.137.0.tgz", + "integrity": "sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4420,13 +4425,16 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.135.0.tgz", - "integrity": "sha512-Y2tkupCG5wo0SxH2rMLG4d4Kmv6DaM3sBp+GuM5lox0S8Za6VxKgQrY2Mut088QQxKkEE89n/4CCCgmw2o0e3Q==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.137.0.tgz", + "integrity": "sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4437,13 +4445,16 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.135.0.tgz", - "integrity": "sha512-xDRJq6i6WTynjeP+ISbDpyH4p9BaJ0wuQcL0lCSDkt9qOXC9dmwpOu1VG/TlwmPI3KpYntmO9nJCuc3TMTsNBA==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.137.0.tgz", + "integrity": "sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4454,13 +4465,16 @@ } }, "node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.135.0.tgz", - "integrity": "sha512-V4MoUuiCRNvihxhIufRxvK+ka013V4joTSK0FAGA1KEjLuNprfH6N/Qw2uxQEVIFuNYMhD/hV6xJ/ptbzlKdHg==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.137.0.tgz", + "integrity": "sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4471,13 +4485,16 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.135.0.tgz", - "integrity": "sha512-JCFZ7zM7KXOKoPAbK/ZB4wY0M1jxRECiem2UQuiXLjzGqS9+hno7mtX+qyK2F7HWK2xPhyJb+frpcOtk5DKOtg==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.137.0.tgz", + "integrity": "sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4488,13 +4505,16 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.135.0.tgz", - "integrity": "sha512-9jSVS1b3hOV7sdKH4aA2DFfnTz0RgQd0v2BefR+LYbH8yIlmSM22JJZbAAjVeVXmFgUAk3zJQ1tpE/Nd+Vi2YQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.137.0.tgz", + "integrity": "sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4505,9 +4525,9 @@ } }, "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.135.0.tgz", - "integrity": "sha512-M857ZLBSdn1Uy/SJJz5zh0qGu67B4P9omCgXGBU2LLqTzraX6ZjVNaKq5yW1PDw/LgJXDXR/dbZfgmB310f11Q==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.137.0.tgz", + "integrity": "sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA==", "cpu": [ "arm64" ], @@ -4522,9 +4542,9 @@ } }, "node_modules/@oxc-parser/binding-wasm32-wasi": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.135.0.tgz", - "integrity": "sha512-2w6DVcntQZX9U5RhXtgiWb3FLWFB5EcwI1U8yr3htOCJUJjagN4BFUHz/Y/d9ZsumndZ6ByxxWEtbUZNE1bfFw==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.137.0.tgz", + "integrity": "sha512-gW2vfkytNGgMVADiuzdvOfw0mWG9za20F/1fCJsif5aBMAvWJTSbpIXbIe0XkOe0VENk+PadpQ7cZgUy2sUJcA==", "cpu": [ "wasm32" ], @@ -4532,18 +4552,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.5" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.135.0.tgz", - "integrity": "sha512-rX1U8+IH2Z37EJjDXKa1iifvUQAdba+vZ4Ewj1iaG5eA/QaSybzclCOwtWa0/5BuUQnnK/T2JHUEFrwhL6Ck2Q==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.137.0.tgz", + "integrity": "sha512-x+pFANF0yL5uK/6T7lu6SlR5qid6sp//eZXKLq5iNsIE+EQg6EaS8/wsW7E91nXXjpnPhSoMOHXShSVhGRdn8w==", "cpu": [ "arm64" ], @@ -4558,9 +4578,9 @@ } }, "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.135.0.tgz", - "integrity": "sha512-9FAisBbH1QICGAjlJobiuKGd/jOuVmyqniWdQMwTa5SkCl6hhuotBCJf1n46B0flYbSOR5TzfV9HZCWSyb3c/Q==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.137.0.tgz", + "integrity": "sha512-sQUqym80PFi6McRsIqfJrSu2JrSClEZIXXD+/FjAFoULEKzOPsldIdFBG96xdX8aVMzCNQ9792FPx3MfkEIrFA==", "cpu": [ "ia32" ], @@ -4575,9 +4595,9 @@ } }, "node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.135.0.tgz", - "integrity": "sha512-wYF+A2AzJ2n7ul6q+Z2G/ia0S2+8cUp0AgWZzoFvF4WmUcl1P7p+o6se1Gdr5wGnWuF0iAMIkGddrjCarNr2yA==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.137.0.tgz", + "integrity": "sha512-2AsevxlvNN4WKxpEn3RtqD5zbqMaXF+T7JXblsP4gVuY+vC9dXS4ED/PwfRCliFqoeisYS3Iro4DHzxr0TEvVA==", "cpu": [ "x64" ], @@ -4592,9 +4612,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.135.0.tgz", - "integrity": "sha512-wR+xRdFkUBMvcAjBJ2q2kcZM6d+DKu2NgoOyxZgYwZdLhmiv6+rnO8PZ/P68kMiZtIKm+pW7zyEJ4kSOs0vo+Q==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", "dev": true, "license": "MIT", "funding": { @@ -4867,17 +4887,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { "version": "11.21.3", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.3.tgz", @@ -4972,30 +4981,16 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", - "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", - "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -5004,12 +4999,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", - "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -5018,12 +5016,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", - "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -5032,26 +5033,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", - "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", - "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -5060,12 +5050,15 @@ "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", - "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -5074,194 +5067,135 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", - "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", - "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", - "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", - "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", - "cpu": [ - "loong64" + "libc": [ + "musl" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", - "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", - "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", - "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", - "cpu": [ - "ppc64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", - "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", - "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", - "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", - "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", - "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", - "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", - "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -5270,12 +5204,34 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", - "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -5284,26 +5240,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", - "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", - "cpu": [ - "ia32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", - "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -5312,30 +5257,26 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", - "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@smithy/core": { - "version": "3.29.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.0.tgz", - "integrity": "sha512-sEvpvkBVoMxjoek35XyJFn2ZD3EJ1RpiZrT47WaZodxzAIWS44zkdvbqGE/ZlugtjiQp62cffYZ9ldyRkjAGnA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.2.tgz", + "integrity": "sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.15.1", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -5343,14 +5284,14 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.5.tgz", - "integrity": "sha512-LnjUTNG0GgQlKIq7IioeOrPaEmC5xOd1WtAz24TLSiYQnWX2uHr53GrFuQhkrJBktPYCMga/NbUOW7hFbSA2Cg==", + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.7.tgz", + "integrity": "sha512-UEMLOoA0Fl4uYBxh6l0uN0H6EJe/A89OGeDNTteQeXpJ20BcpfIr4wlCY9pel1jEAUHAxaYwuqrYlrKdXE1GKQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.0", - "@smithy/types": "^4.15.1", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -5358,14 +5299,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.2.tgz", - "integrity": "sha512-q96PSDOAGw+X+nuELd7Cjebps0SYr+YlPbviEX9sLVw+VM4M7VV8hn1nL1mGS6urDu33eQ5A7WhlphaDO6kUyQ==", + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.4.tgz", + "integrity": "sha512-psnst7NZWdAEvJvyW8YZEE7xNVMyLrQFfHtyrVFrxNyy+dKWkQ+rqC6oI5ZhxThpUy9RSfEshgm34zqbOxzsRw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.0", - "@smithy/types": "^4.15.1", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -5401,14 +5342,14 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.1.tgz", - "integrity": "sha512-SqvuP75p/DmgWWI7jv4kf/UW+V4LFmlUn19s604SgAcRuJRB1vDnWwzZMYCLUcmKxko9wDn6iLgGEIpTNgZbIQ==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.3.tgz", + "integrity": "sha512-8qVKKzqh7naF27ePmx0SkUfnGP/wBI9dyaeAmhHvopnbIlItUAmB/e6PkPCU3rRb2v9BY8D4EZXSoydSibatvw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.0", - "@smithy/types": "^4.15.1", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -5416,9 +5357,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.15.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.1.tgz", - "integrity": "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==", + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.0.tgz", + "integrity": "sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5464,9 +5405,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -5514,9 +5455,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.13.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", - "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5547,17 +5488,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", - "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/type-utils": "8.61.1", - "@typescript-eslint/utils": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -5570,22 +5511,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.61.1", + "@typescript-eslint/parser": "^8.63.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", - "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3" }, "engines": { @@ -5601,14 +5542,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", - "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.61.1", - "@typescript-eslint/types": "^8.61.1", + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "engines": { @@ -5623,14 +5564,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", - "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1" + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5641,9 +5582,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", - "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", "dev": true, "license": "MIT", "engines": { @@ -5658,15 +5599,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", - "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -5683,9 +5624,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", - "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", "dev": true, "license": "MIT", "engines": { @@ -5697,16 +5638,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", - "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.61.1", - "@typescript-eslint/tsconfig-utils": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5725,16 +5666,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", - "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1" + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5749,13 +5690,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", - "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5767,16 +5708,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -5785,13 +5726,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.9", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -5812,9 +5753,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5825,13 +5766,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -5839,14 +5780,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -5855,9 +5796,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -5865,13 +5806,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -6291,10 +6232,20 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", - "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -6427,9 +6378,9 @@ } }, "node_modules/eslint": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", - "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", "workspaces": [ @@ -6832,9 +6783,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastify": { - "version": "5.8.5", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", - "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", "funding": [ { "type": "github", @@ -6853,8 +6804,8 @@ "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", - "fast-json-stringify": "^6.0.0", - "find-my-way": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", @@ -6865,9 +6816,9 @@ } }, "node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", "funding": [ { "type": "github", @@ -6880,6 +6831,68 @@ ], "license": "MIT" }, + "node_modules/fastify/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fastify/node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fastify/node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.0.tgz", + "integrity": "sha512-ZodJ2cRiLVWGi9IgPb3mbgSqM4CD3LexCHkuv0FfBXHJI1ADfucTD06m6clO2Cy5RZYsw/SiCVl/dyrFI/SYWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -7076,9 +7089,9 @@ } }, "node_modules/gaxios": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", - "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.2.0.tgz", + "integrity": "sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -7149,9 +7162,9 @@ } }, "node_modules/globals": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", - "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", "dev": true, "license": "MIT", "engines": { @@ -7541,9 +7554,9 @@ } }, "node_modules/knip": { - "version": "6.17.1", - "resolved": "https://registry.npmjs.org/knip/-/knip-6.17.1.tgz", - "integrity": "sha512-HcQsZSQ4Ymhuay4BVzJtM5pFZNDSomYYqcNCZOSITPQh9g18a09DqziWAxSt2G+BH9wGlG+0ZjWpEnaFlnKseQ==", + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.25.0.tgz", + "integrity": "sha512-Q3n41VjOOB/aqsbxb8kallAcFKrUz3b2S5fD5pTODljVpP01t+rvAgy2x3j0Cq8yEpRRHNdar1vHuqFfGuIakQ==", "dev": true, "funding": [ { @@ -7561,8 +7574,8 @@ "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", - "oxc-parser": "^0.135.0", - "oxc-resolver": "^11.20.0", + "oxc-parser": "^0.137.0", + "oxc-resolver": "11.21.3", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", @@ -7630,6 +7643,279 @@ ], "license": "MIT" }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lit": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz", @@ -7708,9 +7994,9 @@ } }, "node_modules/marked": { - "version": "18.0.5", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "version": "18.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.6.tgz", + "integrity": "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -7810,9 +8096,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.13", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", - "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -7971,13 +8257,13 @@ "license": "MIT" }, "node_modules/oxc-parser": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.135.0.tgz", - "integrity": "sha512-/DaPStu0s2zzNSRRniKyTPM6Z/o+DapOp2JYNKDL8AsgaBGPK2IdZyB87SQjVH+xeQPz+Qr9mrjglfkYgtbVRA==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.137.0.tgz", + "integrity": "sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "^0.135.0" + "@oxc-project/types": "^0.137.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -7986,26 +8272,26 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.135.0", - "@oxc-parser/binding-android-arm64": "0.135.0", - "@oxc-parser/binding-darwin-arm64": "0.135.0", - "@oxc-parser/binding-darwin-x64": "0.135.0", - "@oxc-parser/binding-freebsd-x64": "0.135.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.135.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.135.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.135.0", - "@oxc-parser/binding-linux-arm64-musl": "0.135.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.135.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.135.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.135.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.135.0", - "@oxc-parser/binding-linux-x64-gnu": "0.135.0", - "@oxc-parser/binding-linux-x64-musl": "0.135.0", - "@oxc-parser/binding-openharmony-arm64": "0.135.0", - "@oxc-parser/binding-wasm32-wasi": "0.135.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.135.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.135.0", - "@oxc-parser/binding-win32-x64-msvc": "0.135.0" + "@oxc-parser/binding-android-arm-eabi": "0.137.0", + "@oxc-parser/binding-android-arm64": "0.137.0", + "@oxc-parser/binding-darwin-arm64": "0.137.0", + "@oxc-parser/binding-darwin-x64": "0.137.0", + "@oxc-parser/binding-freebsd-x64": "0.137.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.137.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.137.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.137.0", + "@oxc-parser/binding-linux-arm64-musl": "0.137.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.137.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.137.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.137.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.137.0", + "@oxc-parser/binding-linux-x64-gnu": "0.137.0", + "@oxc-parser/binding-linux-x64-musl": "0.137.0", + "@oxc-parser/binding-openharmony-arm64": "0.137.0", + "@oxc-parser/binding-wasm32-wasi": "0.137.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.137.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.137.0", + "@oxc-parser/binding-win32-x64-msvc": "0.137.0" } }, "node_modules/oxc-resolver": { @@ -8193,9 +8479,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -8253,9 +8539,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -8324,9 +8610,9 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", - "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", @@ -8428,9 +8714,9 @@ } }, "node_modules/read-yaml-file/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -8528,49 +8814,48 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, - "node_modules/rollup": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", - "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.9" + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.0", - "@rollup/rollup-android-arm64": "4.62.0", - "@rollup/rollup-darwin-arm64": "4.62.0", - "@rollup/rollup-darwin-x64": "4.62.0", - "@rollup/rollup-freebsd-arm64": "4.62.0", - "@rollup/rollup-freebsd-x64": "4.62.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", - "@rollup/rollup-linux-arm-musleabihf": "4.62.0", - "@rollup/rollup-linux-arm64-gnu": "4.62.0", - "@rollup/rollup-linux-arm64-musl": "4.62.0", - "@rollup/rollup-linux-loong64-gnu": "4.62.0", - "@rollup/rollup-linux-loong64-musl": "4.62.0", - "@rollup/rollup-linux-ppc64-gnu": "4.62.0", - "@rollup/rollup-linux-ppc64-musl": "4.62.0", - "@rollup/rollup-linux-riscv64-gnu": "4.62.0", - "@rollup/rollup-linux-riscv64-musl": "4.62.0", - "@rollup/rollup-linux-s390x-gnu": "4.62.0", - "@rollup/rollup-linux-x64-gnu": "4.62.0", - "@rollup/rollup-linux-x64-musl": "4.62.0", - "@rollup/rollup-openbsd-x64": "4.62.0", - "@rollup/rollup-openharmony-arm64": "4.62.0", - "@rollup/rollup-win32-arm64-msvc": "4.62.0", - "@rollup/rollup-win32-ia32-msvc": "4.62.0", - "@rollup/rollup-win32-x64-gnu": "4.62.0", - "@rollup/rollup-win32-x64-msvc": "4.62.0", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/rolldown/node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, "node_modules/run-parallel": { @@ -9021,9 +9306,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", "dev": true, "license": "MIT", "dependencies": { @@ -9053,15 +9338,15 @@ } }, "node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.6.tgz", + "integrity": "sha512-Sc8RA0NCMEFmApHNU9ZMzqcpQj46She44J8ffpLM/bdhLNUZKq7DJumcLcsFx1gRmDfQPgCgOmFFJ7rcnfWNyA==", "license": "MIT" }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -9073,16 +9358,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", - "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", + "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.61.1", - "@typescript-eslint/parser": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/utils": "8.61.1" + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9140,18 +9425,17 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", - "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -9167,9 +9451,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -9182,15 +9467,18 @@ "@types/node": { "optional": true }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, "jiti": { "optional": true }, "less": { "optional": true }, - "lightningcss": { - "optional": true - }, "sass": { "optional": true }, @@ -9214,504 +9502,20 @@ } } }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -9739,12 +9543,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 9a9f18b..b21f6db 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "changelog:status": "changeset status" }, "dependencies": { - "@codemirror/commands": "^6.10.3", + "@codemirror/commands": "^6.10.4", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-go": "^6.0.1", "@codemirror/lang-html": "^6.4.11", @@ -60,38 +60,38 @@ "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-rust": "^6.0.2", - "@codemirror/language": "^6.12.3", - "@codemirror/legacy-modes": "^6.5.2", - "@codemirror/state": "^6.6.0", - "@codemirror/view": "^6.42.1", - "@fastify/static": "^9.1.3", - "@fastify/websocket": "^11.2.0", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.3", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.6", + "@fastify/static": "^9.3.0", + "@fastify/websocket": "^11.3.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "diff": "^8.0.4", - "fastify": "^5.6.1", - "lit": "^3.3.1", - "marked": "^18.0.3", + "diff": "^9.0.0", + "fastify": "^5.10.0", + "lit": "^3.3.3", + "marked": "^18.0.6", "node-pty": "^1.1.0", - "typebox": "1.1.38", - "ws": "^8.20.1" + "typebox": "1.3.6", + "ws": "^8.21.0" }, "devDependencies": { "@changesets/cli": "^2.31.0", - "@earendil-works/pi-agent-core": "^0.80.3", - "@earendil-works/pi-ai": "^0.80.3", - "@earendil-works/pi-coding-agent": "^0.80.3", + "@earendil-works/pi-agent-core": "^0.80.6", + "@earendil-works/pi-ai": "^0.80.6", + "@earendil-works/pi-coding-agent": "^0.80.6", "@eslint/js": "^10.0.1", - "@types/node": "^24.10.1", + "@types/node": "^24.13.3", "@types/ws": "^8.18.1", - "eslint": "^10.3.0", - "globals": "^17.6.0", - "knip": "^6.16.1", - "tsx": "^4.20.6", - "typescript": "^5.9.3", - "typescript-eslint": "^8.59.2", - "vite": "^7.2.4", - "vitest": "^4.1.5" + "eslint": "^10.6.0", + "globals": "^17.7.0", + "knip": "^6.25.0", + "tsx": "^4.23.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.63.0", + "vite": "^8.1.4", + "vitest": "^4.1.10" }, "publishConfig": { "access": "public" diff --git a/src/client/src/components/selectableRow.test.ts b/src/client/src/components/selectableRow.test.ts index 48d028e..974784a 100644 --- a/src/client/src/components/selectableRow.test.ts +++ b/src/client/src/components/selectableRow.test.ts @@ -64,9 +64,10 @@ describe("selectable row activation", () => { type EventWithPath = Pick; type KeyboardEventWithPath = EventWithPath & Pick; -type MatchTarget = EventTarget & Pick; +type MatchTarget = EventTarget & { matches: (selector: string) => boolean }; +type MatchPredicate = (selector: string) => boolean; -function matchTarget(matches: Element["matches"]): MatchTarget { +function matchTarget(matches: MatchPredicate): MatchTarget { return Object.assign(new EventTarget(), { matches }); } diff --git a/src/client/src/vite-env.d.ts b/src/client/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/client/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index f9b8f93..198fd93 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -54,8 +54,11 @@ function sessionRef(id: string, cwd = "/workspace") { return { id, cwd }; } +const TEST_MODEL_PROVIDER = "anthropic"; +const TEST_MODEL_ID = "claude-sonnet-4-5-20250929"; + function testModel(): NonNullable { - const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find("anthropic", "claude-3-5-sonnet-20241022"); + const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find(TEST_MODEL_PROVIDER, TEST_MODEL_ID); if (model === undefined) throw new Error("test model not found"); return model; } @@ -1142,7 +1145,7 @@ describe("PiSessionService", () => { const hub = new CapturingSessionEventHub(); const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } }); const modelRegistry = ModelRegistry.inMemory(authStorage); - const model = modelRegistry.find("anthropic", "claude-3-5-sonnet-20241022"); + const model = modelRegistry.find(TEST_MODEL_PROVIDER, TEST_MODEL_ID); if (model === undefined) throw new Error("Expected Anthropic model fixture"); const fake = fakeRuntime("auth-session", { model, modelRegistry }); @@ -1161,7 +1164,7 @@ describe("PiSessionService", () => { service.applyAuthChange({ removedProviderId: "anthropic" }); service.applyAuthChange({ removedProviderId: "anthropic" }); - const warningCount = () => hub.sessionEvents.filter(({ event }) => event.type === "command.output" && event.level === "error" && event.message.includes("anthropic/claude-3-5-sonnet-20241022")).length; + const warningCount = () => hub.sessionEvents.filter(({ event }) => event.type === "command.output" && event.level === "error" && event.message.includes(`${TEST_MODEL_PROVIDER}/${TEST_MODEL_ID}`)).length; expect(warningCount()).toBe(1); expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true); diff --git a/src/shared/thinkingLevels.test.ts b/src/shared/thinkingLevels.test.ts index 19ce76e..9f2a34d 100644 --- a/src/shared/thinkingLevels.test.ts +++ b/src/shared/thinkingLevels.test.ts @@ -30,14 +30,14 @@ describe("thinkingLevels", () => { const known = KNOWN_THINKING_LEVELS; it("derives bar count from the available set (excluding the off level)", () => { - // 6 known levels => 5 bars. - expect(thinkingGauge("off", known).total).toBe(5); + // 7 known levels => 6 bars. + expect(thinkingGauge("off", known).total).toBe(6); expect(thinkingGauge("off", ["off", "low", "high"]).total).toBe(2); }); it("treats the first level as no thinking (0 filled)", () => { - expect(thinkingGauge("off", known)).toEqual({ total: 5, filled: 0 }); - expect(thinkingGauge(undefined, known)).toEqual({ total: 5, filled: 0 }); + expect(thinkingGauge("off", known)).toEqual({ total: 6, filled: 0 }); + expect(thinkingGauge(undefined, known)).toEqual({ total: 6, filled: 0 }); }); it("fills up to the current level's rank", () => { @@ -46,6 +46,7 @@ describe("thinkingLevels", () => { expect(thinkingGauge("medium", known).filled).toBe(3); expect(thinkingGauge("high", known).filled).toBe(4); expect(thinkingGauge("xhigh", known).filled).toBe(5); + expect(thinkingGauge("max", known).filled).toBe(6); }); it("adapts to a runtime-provided set of a different size", () => { @@ -56,8 +57,8 @@ describe("thinkingLevels", () => { }); it("falls back to the known set when no usable available set is given", () => { - expect(thinkingGauge("high", [])).toEqual({ total: 5, filled: 4 }); - expect(thinkingGauge("high", ["only-one"])).toEqual({ total: 5, filled: 4 }); + expect(thinkingGauge("high", [])).toEqual({ total: 6, filled: 4 }); + expect(thinkingGauge("high", ["only-one"])).toEqual({ total: 6, filled: 4 }); }); it("fills 0 for an unknown current level instead of throwing", () => { diff --git a/src/shared/thinkingLevels.ts b/src/shared/thinkingLevels.ts index 8a15427..e9f2c5d 100644 --- a/src/shared/thinkingLevels.ts +++ b/src/shared/thinkingLevels.ts @@ -13,7 +13,7 @@ export type { ThinkingLevel }; * either breaks, update this list and give the new level a label/description * where thinking levels are presented. */ -export const KNOWN_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const satisfies readonly ThinkingLevel[]; +export const KNOWN_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const satisfies readonly ThinkingLevel[]; export function isKnownThinkingLevel(value: string): value is ThinkingLevel { return KNOWN_THINKING_LEVELS.some((level) => level === value); diff --git a/tsconfig.json b/tsconfig.json index c8c9370..b02417b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,7 +25,6 @@ "types": [ "node" ], - "baseUrl": ".", "paths": { "@jmfederico/pi-web/plugin-api": ["./src/plugin-api.ts"], "@jmfederico/pi-web/plugin-api/unstable": ["./src/plugin-api/unstable.ts"] From 8f1b6b91abeca944016811953bbdde34534b21c3 Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Fri, 10 Jul 2026 20:48:05 +0000 Subject: [PATCH 076/111] fix: stream docker helper logs inline --- .changeset/docker-helper-inline-logs.md | 5 ++ docker/README.md | 4 +- docker/pi-web-docker | 34 ++++++++- src/docker/piWebDockerEntrypoint.test.ts | 92 ++++++++++++++++++++++++ src/server/dockerControlAssets.test.ts | 20 +++++- 5 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 .changeset/docker-helper-inline-logs.md create mode 100644 src/docker/piWebDockerEntrypoint.test.ts diff --git a/.changeset/docker-helper-inline-logs.md b/.changeset/docker-helper-inline-logs.md new file mode 100644 index 0000000..12d0b24 --- /dev/null +++ b/.changeset/docker-helper-inline-logs.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Stream Docker update/restart helper logs inline after scheduling detached maintenance work. diff --git a/docker/README.md b/docker/README.md index a23d7cb..9f72d59 100644 --- a/docker/README.md +++ b/docker/README.md @@ -59,7 +59,7 @@ Defaults: Updating recreates the Docker `sessiond` container. Active Pi agent runtimes in this Docker install may stop, so update while sessions are idle. Persisted PI WEB state, Pi config, and session history under the data directory are kept. -Inside the Docker runtime, the Updates panel uses `pi-web-docker` for status, update, and restart commands. Update and restart commands first start a detached helper container with the same Docker/host mounts and generated Compose environment, including the project name, ports/data paths, helper image, and generated UID/GID/Docker group. The helper then runs Docker Compose, so work continues even when `web`, `sessiond`, or the PI WEB terminal that launched the command exits. +Inside the Docker runtime, the Updates panel uses `pi-web-docker` for status, update, and restart commands. Update and restart commands first start a detached helper container with the same Docker/host mounts and generated Compose environment, including the project name, ports/data paths, helper image, and generated UID/GID/Docker group. After scheduling the helper, the command streams that helper's logs inline and prints the `docker logs -f` command needed to reconnect. The helper still runs independently, so work continues even when `web`, `sessiond`, or the PI WEB terminal that launched the command exits. ### Command matrix @@ -301,7 +301,7 @@ Useful development commands: ./docker/pi-web-docker --dev stop ``` -Restart `sessiond` manually after changes that affect `src/server/sessiond.ts`, daemon ownership, or session-daemon-only code paths. Restarting only `web` is enough for ordinary API/client/plugin development reloads. Commands launched from the Updates panel use the same detached `pi-web-docker` helper as runtime mode, so update/restart work continues after the current PI WEB terminal or container exits. In both modes detached helpers load the generated Docker env and run as the generated `PI_WEB_UID:PI_WEB_GID` with the generated Docker group; development helpers still refuse UID 0 unless `--allow-root` is explicit. +Restart `sessiond` manually after changes that affect `src/server/sessiond.ts`, daemon ownership, or session-daemon-only code paths. Restarting only `web` is enough for ordinary API/client/plugin development reloads. Commands launched from the Updates panel use the same detached `pi-web-docker` helper as runtime mode, stream the helper's logs inline after it starts, and keep update/restart work running after the current PI WEB terminal or container exits. In both modes detached helpers load the generated Docker env and run as the generated `PI_WEB_UID:PI_WEB_GID` with the generated Docker group; development helpers still refuse UID 0 unless `--allow-root` is explicit. The dev setup intentionally has the same Docker socket and profile-specific host mounts as the runtime setup. The same trust warnings apply. The command refuses to run development mode as UID 0, or to generate a dev env with `PI_WEB_UID=0`, unless you pass `--allow-root`; use that override only when root-owned checkout writes are intentional. diff --git a/docker/pi-web-docker b/docker/pi-web-docker index 8cf2498..0880d9d 100755 --- a/docker/pi-web-docker +++ b/docker/pi-web-docker @@ -33,7 +33,8 @@ Commands: cli Run the pi-web CLI in the web container Update and restart commands launched inside a PI WEB Docker container start an -independent helper container first so work can continue after web/sessiond exits. +independent helper container first, then stream the helper logs inline. The +helper continues running if the terminal or web/sessiond exits. EOF } @@ -598,6 +599,35 @@ cleanup_old_helpers() { done } +stream_detached_helper_logs() { + helper_name=$1 + printf '\n' + printf 'Streaming detached PI WEB Docker helper logs inline.\n' + printf 'If this terminal disconnects, the helper keeps running.\n' + printf 'Reconnect with: docker logs -f %s\n' "$helper_name" + printf '\n' + + if docker logs -f "$helper_name"; then + logs_status=0 + else + logs_status=$? + fi + + if [ "$logs_status" -ne 0 ]; then + log "pi-web-docker: detached helper log streaming stopped with status $logs_status" + log "pi-web-docker: reconnect with: docker logs -f $helper_name" + return "$logs_status" + fi + + helper_status=$(docker inspect --format '{{.State.ExitCode}}' "$helper_name" 2>/dev/null || true) + if is_unsigned_int "$helper_status" && [ "$helper_status" -ne 0 ]; then + log "pi-web-docker: detached helper exited with status $helper_status" + return "$helper_status" + fi + + return 0 +} + start_detached_helper() { action=$1 is_truthy "${PI_WEB_DOCKER_RUNTIME:-}" || die "detached helpers are only available inside the PI WEB Docker runtime" @@ -675,7 +705,7 @@ start_detached_helper() { container_id=$(docker "$@") || die "could not start detached Docker helper" printf 'Started detached PI WEB Docker helper: %s\n' "$helper_name" printf 'Container ID: %s\n' "$container_id" - printf 'Follow progress with: docker logs -f %s\n' "$helper_name" + stream_detached_helper_logs "$helper_name" } run_detached_action() { diff --git a/src/docker/piWebDockerEntrypoint.test.ts b/src/docker/piWebDockerEntrypoint.test.ts new file mode 100644 index 0000000..bd3147d --- /dev/null +++ b/src/docker/piWebDockerEntrypoint.test.ts @@ -0,0 +1,92 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; + +const execFile = promisify(execFileCallback); +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +describe("pi-web-docker entrypoint", () => { + it("streams detached helper logs inline after scheduling runtime updates", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-docker-entrypoint-")); + try { + const runtimeRoot = join(tempDir, "runtime"); + const binDir = join(tempDir, "bin"); + const dockerCallsPath = join(tempDir, "docker-calls.log"); + await mkdir(runtimeRoot); + await mkdir(binDir); + await writeFile(join(runtimeRoot, ".env"), [ + `PI_WEB_DOCKER_INSTALL_DIR=${runtimeRoot}`, + "COMPOSE_PROJECT_NAME=pi-web-test", + "PI_WEB_UID=1000", + "PI_WEB_GID=1000", + "DOCKER_GID=998", + "PI_WEB_IMAGE=pi-web:test", + "", + ].join("\n")); + + const fakeDockerPath = join(binDir, "docker"); + await writeFile(fakeDockerPath, fakeDockerScript(dockerCallsPath)); + await chmod(fakeDockerPath, 0o755); + + const { stdout, stderr } = await execFile(join(repoRoot, "docker/pi-web-docker"), ["update"], { + env: { + ...process.env, + PATH: `${binDir}:${process.env["PATH"] ?? ""}`, + PI_WEB_DOCKER_RUNTIME: "1", + PI_WEB_DOCKER_MODE: "runtime", + PI_WEB_DOCKER_INSTALL_DIR: runtimeRoot, + PI_WEB_DOCKER_CONTAINER_ID: "current-web-container", + }, + }); + + const output = `${stdout}${stderr}`; + expect(output).toContain("Started detached PI WEB Docker helper: pi-web-docker-update-"); + expect(output).toContain("Streaming detached PI WEB Docker helper logs inline."); + expect(output).toContain("Reconnect with: docker logs -f pi-web-docker-update-"); + expect(output).toContain("helper log: update in progress"); + expect(output).not.toContain("Follow progress with:"); + + const dockerCalls = await readFile(dockerCallsPath, "utf8"); + expect(dockerCalls).toContain("__run-detached update"); + expect(dockerCalls).toMatch(/(?:^|\n)logs -f pi-web-docker-update-\d{14}-\d+(?:\n|$)/); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); + +function fakeDockerScript(dockerCallsPath: string): string { + return `#!/usr/bin/env sh +set -eu +printf '%s\\n' "$*" >> ${shellQuote(dockerCallsPath)} +case "$1" in + ps) + exit 0 + ;; + run) + printf '%s\\n' fake-helper-container-id + ;; + logs) + printf '%s\\n' 'helper log: update in progress' + ;; + inspect) + printf '%s\\n' 0 + ;; + rm) + exit 0 + ;; + *) + printf 'unexpected docker command: %s\\n' "$*" >&2 + exit 42 + ;; +esac +`; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} diff --git a/src/server/dockerControlAssets.test.ts b/src/server/dockerControlAssets.test.ts index 96ebbc2..f356a4d 100644 --- a/src/server/dockerControlAssets.test.ts +++ b/src/server/dockerControlAssets.test.ts @@ -309,7 +309,9 @@ describe("Docker command assets", () => { const result = await runDockerCommand(["restart-sessiond"], runtimeEnv(fakeDocker, installDir)); expect(result.stdout).toContain("Started detached PI WEB Docker helper"); - expect(result.stdout).toContain("Follow progress with: docker logs -f pi-web-docker-restart-sessiond-"); + expect(result.stdout).toContain("Streaming detached PI WEB Docker helper logs inline."); + expect(result.stdout).toContain("Reconnect with: docker logs -f pi-web-docker-restart-sessiond-"); + expect(result.stdout).toContain("fake helper log"); const log = await readFile(fakeDocker.logPath, "utf8"); expect(log).toContain("container inspect"); expect(log).toContain("run -d"); @@ -518,6 +520,22 @@ case "\${1:-}" in printf 'fake-helper-container-id\n' exit 0 ;; + logs) + printf 'fake helper log\n' + exit 0 + ;; + inspect) + for arg in "$@"; do + case "$arg" in + *State.ExitCode*) + printf '0\n' + exit 0 + ;; + esac + done + printf '{}\n' + exit 0 + ;; esac printf 'unexpected fake docker args: %s\n' "$*" >&2 exit 9 From 3df11e3eb06b96f695553a8249aad826d6837638 Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Fri, 10 Jul 2026 21:20:14 +0000 Subject: [PATCH 077/111] fix(docker): avoid FIPS solver prompts --- .changeset/steady-fips-docker-builds.md | 5 +++++ docker/internal/image/install-opensuse-base | 7 +++++++ 2 files changed, 12 insertions(+) create mode 100644 .changeset/steady-fips-docker-builds.md diff --git a/.changeset/steady-fips-docker-builds.md b/.changeset/steady-fips-docker-builds.md new file mode 100644 index 0000000..48c7cc9 --- /dev/null +++ b/.changeset/steady-fips-docker-builds.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Avoid interactive openSUSE FIPS crypto-policy solver conflicts during Docker image builds. diff --git a/docker/internal/image/install-opensuse-base b/docker/internal/image/install-opensuse-base index f82c251..3585e44 100755 --- a/docker/internal/image/install-opensuse-base +++ b/docker/internal/image/install-opensuse-base @@ -53,6 +53,13 @@ add_nodejs_repo() { # refreshes noisy or brittle when its signing key rolls independently. zypper --non-interactive modifyrepo --disable repo-openh264 >/dev/null 2>&1 || true +# Some Tumbleweed snapshots include the FIPS base pattern. This image does not +# enforce FIPS mode, and the pattern can turn normal package installs into +# interactive crypto-policy solver choices while repository metadata is in flux. +if rpm -q patterns-base-fips >/dev/null 2>&1; then + zypper --non-interactive remove patterns-base-fips +fi + add_nodejs_repo zypper --gpg-auto-import-keys --non-interactive refresh From a660ba8ef882337443afc5bbbdb046855f4feff4 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 11 Jul 2026 09:41:03 +0200 Subject: [PATCH 078/111] fix(sessions): clarify subsession waiting behavior --- .changeset/clarify-subsession-waiting.md | 5 +++++ src/server/sessions/spawnSubsessionTool.test.ts | 13 +++++++++++++ src/server/sessions/spawnSubsessionTool.ts | 4 ++-- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 .changeset/clarify-subsession-waiting.md diff --git a/.changeset/clarify-subsession-waiting.md b/.changeset/clarify-subsession-waiting.md new file mode 100644 index 0000000..cb3dd4c --- /dev/null +++ b/.changeset/clarify-subsession-waiting.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Clarify tracked-subsession guidance so agents continue independent work or end their turn instead of polling while child sessions run. diff --git a/src/server/sessions/spawnSubsessionTool.test.ts b/src/server/sessions/spawnSubsessionTool.test.ts index 3201653..da9f746 100644 --- a/src/server/sessions/spawnSubsessionTool.test.ts +++ b/src/server/sessions/spawnSubsessionTool.test.ts @@ -52,6 +52,19 @@ describe("createSubsessionToolDefinitions", () => { expect(firstText(result.content)).toContain("Started subsession child-1"); }); + it("tells the parent to work independently or end its turn instead of polling", async () => { + const { spawn: spawnTool } = tools({ + spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" })), + }); + + expect(spawnTool.description).toContain("Do not poll or sleep while waiting"); + + const result = await spawnTool.execute("call-guidance", { prompt: "do it" }, undefined, undefined, ctxFor("parent-1", undefined)); + const message = firstText(result.content); + expect(message).toContain("Continue independent work or end this turn if blocked; do not poll"); + expect(message).toContain("You will be resumed when it stops working"); + }); + it("spawn_subsession omits the inherited model when the dispatching session has no current model", async () => { const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-2", cwd: "/repos/a" })); const { spawn: spawnTool } = tools({ spawn }); diff --git a/src/server/sessions/spawnSubsessionTool.ts b/src/server/sessions/spawnSubsessionTool.ts index 68792e6..6498bf7 100644 --- a/src/server/sessions/spawnSubsessionTool.ts +++ b/src/server/sessions/spawnSubsessionTool.ts @@ -178,7 +178,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse const spawnTool = defineTool({ name: "spawn_subsession", label: "Spawn subsession", - description: "Start a tracked child session and send it an initial prompt. The subsession runs independently and a human can interact with it, but unlike spawn_session it is linked to you: you are notified when it stops working (finished, idle, or errored), and you can inspect it with list_subsessions, check_subsession (a quick glance at its latest output), and read_subsession (read through its transcript). Use this to delegate work you intend to follow up on.", + description: "Start an asynchronous tracked child session. The call returns after dispatch. When the child becomes idle or errors, a notification starts a new parent turn or queues behind the current one. Do not poll or sleep while waiting: continue useful independent work, or end this turn normally if blocked. Inspect only when immediately actionable.", promptSnippet: "spawn_subsession: start a tracked child session you will be notified about", parameters: SpawnSubsessionParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { @@ -193,7 +193,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse ...(ctx.model === undefined ? {} : { model: ctx.model }), }); return { - content: [{ type: "text", text: `Started subsession ${result.sessionId} in ${result.cwd}. You will be notified when it stops working.` }], + content: [{ type: "text", text: `Started subsession ${result.sessionId} in ${result.cwd}. Continue independent work or end this turn if blocked; do not poll. You will be resumed when it stops working.` }], details: result, }; }, From 52925c14059f1cb0d60aacc03940afd16df99f1b Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 11 Jul 2026 15:56:20 +0200 Subject: [PATCH 079/111] fix(sessions): restrict delegation tools in tracked children --- .changeset/clarify-subsession-waiting.md | 2 +- .../piSessionService.delegationTools.test.ts | 109 ++++++++ .../piSessionService.spawnSession.test.ts | 3 + .../piSessionService.spawnSubsession.test.ts | 14 +- .../sessions/piSessionService.testSupport.ts | 2 + src/server/sessions/piSessionService.ts | 243 +++++++++++++----- src/server/sessions/spawnSessionTool.test.ts | 9 +- src/server/sessions/spawnSessionTool.ts | 4 +- .../sessions/spawnSubsessionTool.test.ts | 25 +- src/server/sessions/spawnSubsessionTool.ts | 26 +- 10 files changed, 341 insertions(+), 96 deletions(-) create mode 100644 src/server/sessions/piSessionService.delegationTools.test.ts diff --git a/.changeset/clarify-subsession-waiting.md b/.changeset/clarify-subsession-waiting.md index cb3dd4c..2ebeec3 100644 --- a/.changeset/clarify-subsession-waiting.md +++ b/.changeset/clarify-subsession-waiting.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Clarify tracked-subsession guidance so agents continue independent work or end their turn instead of polling while child sessions run. +Keep delegation tools available to human-created and independently spawned sessions, remove them from tracked child sessions, and make delegation tool contracts capability-focused. diff --git a/src/server/sessions/piSessionService.delegationTools.test.ts b/src/server/sessions/piSessionService.delegationTools.test.ts new file mode 100644 index 0000000..0e1f0f5 --- /dev/null +++ b/src/server/sessions/piSessionService.delegationTools.test.ts @@ -0,0 +1,109 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPiWebCustomToolDefinitions, sessionAllowsDelegationTools, type PiSessionManager } from "./piSessionService.js"; +import type { SubsessionToolDeps } from "./spawnSubsessionTool.js"; +import { fakeSessionManager } from "./piSessionService.testSupport.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +function delegationDeps() { + const spawn = vi.fn(() => Promise.resolve({ sessionId: "independent-1", cwd: "/workspace" })); + const subsessions: SubsessionToolDeps = { + spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace" })), + list: vi.fn(() => Promise.resolve([])), + check: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, finalText: "", messageCount: 0 })), + read: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, entries: [], total: 0, matched: 0, start: 0, hasMore: false })), + }; + return { spawn, subsessions }; +} + +function toolNames(definitions: ReturnType): string[] { + return definitions.map((definition) => definition.name); +} + +function manager(id: string, file: string | undefined, entries: readonly unknown[] = []): PiSessionManager { + return fakeSessionManager("/workspace", { + getSessionId: () => id, + getSessionFile: () => file, + getEntries: () => entries, + }); +} + +describe("delegation tool capability boundary", () => { + it("provides every globally enabled delegation tool to unrestricted sessions", () => { + const { spawn, subsessions } = delegationDeps(); + + expect(toolNames(createPiWebCustomToolDefinitions("/workspace", true, spawn, subsessions))).toEqual([ + "edit", + "spawn_session", + "spawn_subsession", + "list_subsessions", + "check_subsession", + "read_subsession", + ]); + }); + + it("continues to honor global delegation feature flags for unrestricted sessions", () => { + const { spawn } = delegationDeps(); + + expect(toolNames(createPiWebCustomToolDefinitions("/workspace", true, spawn))).toEqual(["edit", "spawn_session"]); + expect(toolNames(createPiWebCustomToolDefinitions("/workspace", true))).toEqual(["edit"]); + }); + + it("removes every delegation tool but retains ordinary tools for restricted tracked children", () => { + const { spawn, subsessions } = delegationDeps(); + + expect(toolNames(createPiWebCustomToolDefinitions("/workspace", false, spawn, subsessions))).toEqual(["edit"]); + }); + + it.each(["human-created", "spawn_session-created"])("allows delegation for a %s session without tracked-child provenance", async () => { + const sessionManager = manager("session-1", undefined); + const open = vi.fn(() => { throw new Error("no parent session should be opened"); }); + + await expect(sessionAllowsDelegationTools(sessionManager, { open })).resolves.toBe(true); + expect(open).not.toHaveBeenCalled(); + }); + + it("removes delegation when persisted records verify exact tracked-child provenance", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-web-delegation-provenance-")); + tempDirs.push(dir); + const parentFile = join(dir, "parent.jsonl"); + const childFile = join(dir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace", parentSession: parentFile })}\n`, "utf8"); + + const childManager = manager("child-1", childFile, [ + { type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }, + ]); + const parentManager = manager("parent-1", parentFile, [ + { type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace" } }, + ]); + + await expect(sessionAllowsDelegationTools(childManager, { open: () => parentManager })).resolves.toBe(false); + }); + + it("does not treat a copied child marker as tracked provenance without an exact reciprocal file link", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-web-delegation-copy-")); + tempDirs.push(dir); + const parentFile = join(dir, "parent.jsonl"); + const originalChildFile = join(dir, "original-child.jsonl"); + const copiedChildFile = join(dir, "copied-child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace", parentSession: parentFile })}\n`, "utf8"); + + const copiedChildManager = manager("child-1", copiedChildFile, [ + { type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }, + ]); + const parentManager = manager("parent-1", parentFile, [ + { type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace" } }, + ]); + + await expect(sessionAllowsDelegationTools(copiedChildManager, { open: () => parentManager })).resolves.toBe(true); + }); +}); diff --git a/src/server/sessions/piSessionService.spawnSession.test.ts b/src/server/sessions/piSessionService.spawnSession.test.ts index 8f3e51d..0f1244b 100644 --- a/src/server/sessions/piSessionService.spawnSession.test.ts +++ b/src/server/sessions/piSessionService.spawnSession.test.ts @@ -33,9 +33,11 @@ describe("PiSessionService", () => { const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" }); const model = testModel(); let initialModel: PiAgentSession["model"]; + let delegationToolsEnabled: boolean | undefined; const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { await Promise.resolve(); initialModel = options.initialModel; + delegationToolsEnabled = options.delegationToolsEnabled; return fake.runtime; }; const service = new PiSessionService(new CapturingSessionEventHub(), { @@ -48,6 +50,7 @@ describe("PiSessionService", () => { await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue", cwd: "/workspace-feature", model }); expect(initialModel).toBe(model); + expect(delegationToolsEnabled).toBe(true); await service.dispose(); }); diff --git a/src/server/sessions/piSessionService.spawnSubsession.test.ts b/src/server/sessions/piSessionService.spawnSubsession.test.ts index b695529..0ba16fa 100644 --- a/src/server/sessions/piSessionService.spawnSubsession.test.ts +++ b/src/server/sessions/piSessionService.spawnSubsession.test.ts @@ -55,16 +55,18 @@ describe("PiSessionService", () => { await service.dispose(); }); - it("uses the parent session's model as the tracked child's initial model", async () => { + it("uses the parent model and disables delegation before creating the tracked child runtime", async () => { const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" }); const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") }); const model = testModel(); const initialModels: PiAgentSession["model"][] = []; + const delegationCapabilities: boolean[] = []; const runtimes = [parent.runtime, child.runtime]; let index = 0; const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { await Promise.resolve(); initialModels.push(options.initialModel); + delegationCapabilities.push(options.delegationToolsEnabled); const runtime = runtimes[index] ?? child.runtime; index += 1; return runtime; @@ -81,6 +83,7 @@ describe("PiSessionService", () => { await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model }); expect(initialModels).toEqual([undefined, model]); + expect(delegationCapabilities).toEqual([true, false]); await service.dispose(); }); @@ -306,19 +309,25 @@ describe("PiSessionService", () => { try { const childManager = fakeSessionManager("/workspace-feature", { + getSessionId: () => "child-1", + getSessionFile: () => childFile, getHeader: () => ({ parentSession: parentFile }), getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], }); const parentManager = fakeSessionManager("/workspace", { + getSessionId: () => "parent-1", + getSessionFile: () => parentFile, getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], }); const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); const runtimes = [child.runtime, parent.runtime]; + const delegationCapabilities: boolean[] = []; let index = 0; const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { + createAgentRuntime: (_createRuntime, options) => { + delegationCapabilities.push(options.delegationToolsEnabled); const runtime = runtimes[index] ?? parent.runtime; index += 1; return Promise.resolve(runtime); @@ -342,6 +351,7 @@ describe("PiSessionService", () => { expect(parent.calls.sendCustomMessage).toHaveLength(1); expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); + expect(delegationCapabilities).toEqual([false, true]); expect(open).toHaveBeenCalledWith(parentFile); await service.dispose(); } finally { diff --git a/src/server/sessions/piSessionService.testSupport.ts b/src/server/sessions/piSessionService.testSupport.ts index bd7f9c0..0281cef 100644 --- a/src/server/sessions/piSessionService.testSupport.ts +++ b/src/server/sessions/piSessionService.testSupport.ts @@ -33,6 +33,8 @@ export interface TestSession extends PiAgentSession { export function fakeSessionManager(cwd = "/workspace", patch: Partial = {}): PiSessionManager { return { getCwd: () => cwd, + getSessionId: () => "session-1", + getSessionFile: () => undefined, getBranch: () => [], getLeafId: () => "leaf-1", ...patch, diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index fa5f783..3affafe 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -104,11 +104,17 @@ interface PersistedChildSubsessionLink { spawnedSessionId: string; } +type SessionCreationProvenance = "tracked-subsession"; + interface StartSessionOptions { parentSession?: string; initialModel?: AgentModel; } +interface InternalStartSessionOptions extends StartSessionOptions { + creationProvenance?: SessionCreationProvenance; +} + function requirePromptText(value: unknown): string { if (typeof value !== "string") throw new Error("Prompt text is required"); return value; @@ -167,6 +173,8 @@ type ModelRegistryInstance = ReturnType; export interface PiSessionManager { getCwd(): string; + getSessionId(): string; + getSessionFile(): string | undefined; getBranch(): unknown[]; getEntries?(): readonly unknown[]; getLeafId(): string | null; @@ -257,18 +265,24 @@ interface CreateAgentRuntimeOptions { cwd: string; agentDir: string; sessionManager: PiSessionManager; + delegationToolsEnabled: boolean; initialModel?: AgentModel; } +type PiWebRuntimeFactoryOptions = Parameters[0] & { + delegationToolsEnabled?: boolean; + initialModel?: AgentModel; +}; + type PiWebCreateAgentSessionRuntimeFactory = ( - options: Parameters[0] & { initialModel?: AgentModel } + options: PiWebRuntimeFactoryOptions ) => ReturnType; type CreateAgentRuntime = (createRuntime: PiWebCreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions) => Promise; function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise { if (!(options.sessionManager instanceof SessionManager)) throw new Error("Default runtime creation requires an SDK SessionManager"); - const runtimeFactory = createRuntimeWithOneShotInitialModel(createRuntime, options.initialModel); + const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.delegationToolsEnabled); return createAgentSessionRuntime(runtimeFactory, { cwd: options.cwd, agentDir: options.agentDir, @@ -276,30 +290,55 @@ function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntime }); } -function createRuntimeWithOneShotInitialModel(createRuntime: PiWebCreateAgentSessionRuntimeFactory, initialModel: AgentModel | undefined): CreateAgentSessionRuntimeFactory { - // The inherited model belongs only to the session being spawned. Do not keep - // reapplying it if that runtime later creates/forks/switches sessions itself. +function createRuntimeWithOneShotSessionOptions( + createRuntime: PiWebCreateAgentSessionRuntimeFactory, + initialModel: AgentModel | undefined, + delegationToolsEnabled: boolean, +): CreateAgentSessionRuntimeFactory { + // These inputs belong only to the session being opened. A later runtime + // replacement resolves its own model and delegation capability. let pendingInitialModel = initialModel; + let pendingDelegationToolsEnabled: boolean | undefined = delegationToolsEnabled; return async (options) => { const model = pendingInitialModel; + const toolsEnabled = pendingDelegationToolsEnabled; pendingInitialModel = undefined; + pendingDelegationToolsEnabled = undefined; return createRuntime({ ...options, ...(model === undefined ? {} : { initialModel: model }), + ...(toolsEnabled === undefined ? {} : { delegationToolsEnabled: toolsEnabled }), }); }; } type SpawnSessionFn = (input: SpawnSessionInvocation) => Promise; -function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps): PiWebCreateAgentSessionRuntimeFactory { - return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel }) => { +export function createPiWebCustomToolDefinitions( + cwd: string, + delegationEnabled: boolean, + spawn?: SpawnSessionFn, + subsessions?: SubsessionToolDeps, +) { + return [ + createPiWebEditToolDefinition(cwd), + ...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []), + ...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []), + ]; +} + +function createDefaultRuntimeFactory( + authStorage: AuthStorage, + modelRegistry: ModelRegistryInstance, + sessionManagers: Pick, + spawn?: SpawnSessionFn, + subsessions?: SubsessionToolDeps, +): PiWebCreateAgentSessionRuntimeFactory { + return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => { const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry }); - const customTools = [ - createPiWebEditToolDefinition(cwd), - ...(spawn === undefined ? [] : [createSpawnSessionToolDefinition(cwd, { spawn })]), - ...(subsessions === undefined ? [] : createSubsessionToolDefinitions(cwd, subsessions)), - ]; + const resolvedDelegationToolsEnabled = delegationToolsEnabled + ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers); + const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions); const result = await createAgentSessionFromServices({ services, sessionManager, @@ -345,17 +384,16 @@ export interface PiSessionServiceDependencies { heartbeatIntervalMs?: number; workspaceActivity?: Pick; /** - * When provided, the `spawn_session` tool is registered on every session, - * letting the LLM start new sessions scoped to its project's workspaces. - * Omit to keep the capability disabled (the tool is never registered). + * When provided, `spawn_session` is available to sessions whose creation + * provenance permits delegation, scoped to the project's workspaces. + * Omit to keep the capability disabled. */ spawnTargets?: SpawnTargetResolver; /** * Beta: when true (and `spawnTargets` is provided), the tracked-subsession - * tools (`spawn_subsession`, `list_subsessions`, `check_subsession`, - * `read_subsession`) are - * registered on every session. Off by default so the capability can ship in - * main without being exposed in releases. + * tools are available to sessions whose creation provenance permits + * delegation. Off by default so the capability can ship in main without + * being exposed in releases. */ subsessionsEnabled?: boolean; /** Structured logger for notable runtime events (e.g. spawns). */ @@ -411,6 +449,7 @@ export class PiSessionService { this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory( this.modelRegistry.authStorage, this.modelRegistry, + this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : { spawn: (input) => this.spawnSubsession(input), @@ -531,10 +570,17 @@ export class PiSessionService { } async start(cwd: string, options: StartSessionOptions = {}): Promise { + return this.startSession(cwd, options); + } + + private async startSession(cwd: string, options: InternalStartSessionOptions): Promise { const active = await this.create( this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, - options.initialModel === undefined ? {} : { initialModel: options.initialModel }, + { + ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }), + ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }), + }, ); const { session } = active.runtime; const created: ClientSession = { @@ -584,9 +630,10 @@ export class PiSessionService { if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled"); const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd); if (!decision.allowed) throw spawnTargetError(decision); - const created = await this.start(decision.cwd, { + const created = await this.startSession(decision.cwd, { ...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }), ...(input.model === undefined ? {} : { initialModel: input.model }), + creationProvenance: "tracked-subsession", }); const parentSessionFile = nonEmptyString(input.parentSessionFile); const link: TrackedSubsessionLink = { @@ -795,53 +842,13 @@ export class PiSessionService { this.registerVerifiedSubsession(link); } - private async verifiedSubsessionLinkFromOpenedChild(session: PiAgentSession): Promise { - // Child markers are only hints; the current child header and reciprocal - // parent custom link must agree on the exact ids and files before relinking. - const entries = session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch(); - let marker: PersistedChildSubsessionLink | undefined; - for (const entry of entries) { - const parsed = parsePersistedChildSubsessionLink(entry); - if (parsed?.spawnedSessionId === session.sessionId) marker = parsed; - } - if (marker === undefined) return undefined; - - const childSessionFile = nonEmptyString(session.sessionFile); - if (childSessionFile === undefined) return undefined; - const childHeader = await readSessionHeaderSummary(childSessionFile); - if (childHeader?.id !== session.sessionId) return undefined; - const parentSessionFile = nonEmptyString(childHeader.parentSession); - if (parentSessionFile === undefined) return undefined; - const parentHeader = await readSessionHeaderSummary(parentSessionFile); - if (parentHeader?.id !== marker.spawnedBySessionId) return undefined; - - const parentLink = this.findReciprocalParentSubsessionLink(parentSessionFile, marker.spawnedBySessionId, session.sessionId, childSessionFile); - if (parentLink === undefined) return undefined; - return { - parentSessionId: marker.spawnedBySessionId, - childSessionId: session.sessionId, - childSessionFile, - parentSessionFile, - cwd: parentLink.cwd ?? session.sessionManager.getCwd(), - }; - } - - private findReciprocalParentSubsessionLink(parentSessionFile: string, parentSessionId: string, childSessionId: string, childSessionFile: string): PersistedParentSubsessionLink | undefined { - let parentManager: PiSessionManager; - try { - parentManager = this.sessionManager.open(parentSessionFile); - } catch { - return undefined; - } - const entries = parentManager.getEntries?.() ?? parentManager.getBranch(); - for (const entry of entries) { - const link = parsePersistedParentSubsessionLink(entry); - if (link === undefined) continue; - if (link.spawnedBySessionId !== parentSessionId || link.spawnedSessionId !== childSessionId) continue; - if (link.spawnedSessionFile === undefined || !sessionPathsEqual(link.spawnedSessionFile, childSessionFile)) continue; - return link; - } - return undefined; + private verifiedSubsessionLinkFromOpenedChild(session: PiAgentSession): Promise { + return verifiedTrackedSubsessionLink(this.sessionManager, { + sessionId: session.sessionId, + sessionFile: session.sessionFile, + sessionManager: session.sessionManager, + cwd: session.sessionManager.getCwd(), + }); } private async getOrOpenTrackedSubsession(sessionId: string): Promise { @@ -898,7 +905,7 @@ export class PiSessionService { const status: SubsessionStatus = this.activities.get(childId)?.phase === "error" ? "error" : "idle"; const finalText = finalAssistantText(historyMessages(session)); const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText); - const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nUse check_subsession with sessionId "${childId}" for its status and latest output, or read_subsession to look through its full transcript.`; + const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nStatus and latest output are available through check_subsession with sessionId "${childId}"; its full transcript is available through read_subsession.`; void this.notifyParentOfSubsession(link.parentSessionId, childId, text); } @@ -1592,11 +1599,18 @@ export class PiSessionService { return undefined; } - private async create(sessionManager: PiSessionManager, cwd: string, options: Pick = {}): Promise> { + private async create( + sessionManager: PiSessionManager, + cwd: string, + options: Pick = {}, + ): Promise> { + const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession" + && await sessionAllowsDelegationTools(sessionManager, this.sessionManager); const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager, + delegationToolsEnabled, ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }), }); await this.bindSessionExtensions(runtime.session); @@ -2095,6 +2109,95 @@ function isDefined(value: T | undefined): value is T { return value !== undefined; } +interface TrackedSubsessionSessionIdentity { + sessionId: string; + sessionFile: string | undefined; + sessionManager: PiSessionManager; + cwd: string; +} + +/** + * Resolve the delegation capability from server-owned, persisted session + * provenance. A copied marker is not enough: the child header and reciprocal + * parent link must identify the exact same session files. + */ +export async function sessionAllowsDelegationTools( + sessionManager: PiSessionManager, + managers: Pick, +): Promise { + const trackedLink = await verifiedTrackedSubsessionLink(managers, { + sessionId: sessionManager.getSessionId(), + sessionFile: sessionManager.getSessionFile(), + sessionManager, + cwd: sessionManager.getCwd(), + }); + return trackedLink === undefined; +} + +async function verifiedTrackedSubsessionLink( + managers: Pick, + session: TrackedSubsessionSessionIdentity, +): Promise { + // Child markers are only hints; the current child header and reciprocal + // parent custom link must agree on the exact ids and files before relinking. + const entries = session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch(); + let marker: PersistedChildSubsessionLink | undefined; + for (const entry of entries) { + const parsed = parsePersistedChildSubsessionLink(entry); + if (parsed?.spawnedSessionId === session.sessionId) marker = parsed; + } + if (marker === undefined) return undefined; + + const childSessionFile = nonEmptyString(session.sessionFile); + if (childSessionFile === undefined) return undefined; + const childHeader = await readSessionHeaderSummary(childSessionFile); + if (childHeader?.id !== session.sessionId) return undefined; + const parentSessionFile = nonEmptyString(childHeader.parentSession); + if (parentSessionFile === undefined) return undefined; + const parentHeader = await readSessionHeaderSummary(parentSessionFile); + if (parentHeader?.id !== marker.spawnedBySessionId) return undefined; + + const parentLink = findReciprocalParentSubsessionLink( + managers, + parentSessionFile, + marker.spawnedBySessionId, + session.sessionId, + childSessionFile, + ); + if (parentLink === undefined) return undefined; + return { + parentSessionId: marker.spawnedBySessionId, + childSessionId: session.sessionId, + childSessionFile, + parentSessionFile, + cwd: parentLink.cwd ?? session.cwd, + }; +} + +function findReciprocalParentSubsessionLink( + managers: Pick, + parentSessionFile: string, + parentSessionId: string, + childSessionId: string, + childSessionFile: string, +): PersistedParentSubsessionLink | undefined { + let parentManager: PiSessionManager; + try { + parentManager = managers.open(parentSessionFile); + } catch { + return undefined; + } + const entries = parentManager.getEntries?.() ?? parentManager.getBranch(); + for (const entry of entries) { + const link = parsePersistedParentSubsessionLink(entry); + if (link === undefined) continue; + if (link.spawnedBySessionId !== parentSessionId || link.spawnedSessionId !== childSessionId) continue; + if (link.spawnedSessionFile === undefined || !sessionPathsEqual(link.spawnedSessionFile, childSessionFile)) continue; + return link; + } + return undefined; +} + function trackedSubsessionLinkFromParentLink(parentSessionId: string, link: PersistedParentSubsessionLink, parentSessionFile: string): TrackedSubsessionLink { return { parentSessionId, diff --git a/src/server/sessions/spawnSessionTool.test.ts b/src/server/sessions/spawnSessionTool.test.ts index ea19549..3c23363 100644 --- a/src/server/sessions/spawnSessionTool.test.ts +++ b/src/server/sessions/spawnSessionTool.test.ts @@ -17,7 +17,14 @@ describe("createSpawnSessionToolDefinition", () => { expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "do the thing", cwd: "/repos/a-feature", model: dispatchModel }); expect(result.details).toEqual({ sessionId: "new-1", cwd: "/repos/a-feature" }); - expect(result.content[0]).toMatchObject({ type: "text", text: "Started session new-1 in /repos/a-feature." }); + expect(result.content[0]).toMatchObject({ type: "text", text: "Started independent session new-1 in /repos/a-feature." }); + }); + + it("describes the independent-session capability without workflow policy", () => { + const tool = createSpawnSessionToolDefinition("/repos/a", { spawn: vi.fn() }); + + expect(tool.description).toBe("Start a new independent pi-web session and send it an initial prompt. The session is not tracked by the caller, can be opened by a human, and runs without returning its later output to the caller."); + expect(tool.description).not.toMatch(/use this|continue work|follow a plan|relay/i); }); it("forwards omitted cwd as undefined and omits a missing dispatching model", async () => { diff --git a/src/server/sessions/spawnSessionTool.ts b/src/server/sessions/spawnSessionTool.ts index 9696520..43cbb1f 100644 --- a/src/server/sessions/spawnSessionTool.ts +++ b/src/server/sessions/spawnSessionTool.ts @@ -41,7 +41,7 @@ export function createSpawnSessionToolDefinition(spawningCwd: string, deps: Spaw return defineTool({ name: "spawn_session", label: "Spawn session", - description: "Start a new, independent pi-web session and send it an initial prompt. Use this to dispatch a fresh agent to continue work or follow a plan. The new session runs on its own and a human can interact with it; you do not receive its output.", + description: "Start a new independent pi-web session and send it an initial prompt. The session is not tracked by the caller, can be opened by a human, and runs without returning its later output to the caller.", promptSnippet: "spawn_session: start a new independent session with a first prompt", parameters: SpawnSessionParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { @@ -55,7 +55,7 @@ export function createSpawnSessionToolDefinition(spawningCwd: string, deps: Spaw ...(ctx.model === undefined ? {} : { model: ctx.model }), }); return { - content: [{ type: "text", text: `Started session ${result.sessionId} in ${result.cwd}.` }], + content: [{ type: "text", text: `Started independent session ${result.sessionId} in ${result.cwd}.` }], details: result, }; }, diff --git a/src/server/sessions/spawnSubsessionTool.test.ts b/src/server/sessions/spawnSubsessionTool.test.ts index da9f746..c49999b 100644 --- a/src/server/sessions/spawnSubsessionTool.test.ts +++ b/src/server/sessions/spawnSubsessionTool.test.ts @@ -49,20 +49,31 @@ describe("createSubsessionToolDefinitions", () => { model: dispatchModel, }); expect(result.details).toEqual({ sessionId: "child-1", cwd: "/repos/a-feature" }); - expect(firstText(result.content)).toContain("Started subsession child-1"); + expect(firstText(result.content)).toContain("Started tracked subsession child-1"); }); - it("tells the parent to work independently or end its turn instead of polling", async () => { + it("describes tracked dispatch and notification without workflow policy", async () => { const { spawn: spawnTool } = tools({ spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" })), }); - expect(spawnTool.description).toContain("Do not poll or sleep while waiting"); + expect(spawnTool.description).toBe("Start a tracked child session and send it an initial prompt. The call returns after dispatch; the parent is notified when the child stops working and can inspect its status, latest output, and transcript."); - const result = await spawnTool.execute("call-guidance", { prompt: "do it" }, undefined, undefined, ctxFor("parent-1", undefined)); + const result = await spawnTool.execute("call-contract", { prompt: "do it" }, undefined, undefined, ctxFor("parent-1", undefined)); const message = firstText(result.content); - expect(message).toContain("Continue independent work or end this turn if blocked; do not poll"); - expect(message).toContain("You will be resumed when it stops working"); + expect(message).toBe("Started tracked subsession child-1 in /repos/a-feature. The parent will be notified when it stops working."); + expect(`${spawnTool.description}\n${message}`).not.toMatch(/do not poll|continue (?:useful|independent) work|end (?:this|the) turn|relay/i); + }); + + it("keeps all subsession tool descriptions capability-oriented", () => { + const definitions = tools({}); + + expect(definitions.list.description).toBe("List tracked child sessions owned by the calling session, with each child's current status (working, idle, error, or unknown)."); + expect(definitions.check.description).toBe("Return a tracked subsession's current status, message count, and most recent assistant output."); + expect(definitions.read.description).toBe("Return a filtered, paginated transcript of a tracked subsession. Filters select message roles and content kinds, search full message content, optionally include raw tool arguments, and cap or page the returned entries."); + for (const definition of Object.values(definitions)) { + expect(definition.description).not.toMatch(/use this|do not poll|continue working|start narrow|for just the final|relay/i); + } }); it("spawn_subsession omits the inherited model when the dispatching session has no current model", async () => { @@ -100,7 +111,7 @@ describe("createSubsessionToolDefinitions", () => { it("list_subsessions reports an empty state", async () => { const { list: listTool } = tools({ list: vi.fn(() => Promise.resolve([])) }); const result = await listTool.execute("call-3", {}, undefined, undefined, ctxFor("parent-1", undefined)); - expect(result.content[0]).toMatchObject({ type: "text", text: "You have not spawned any subsessions." }); + expect(result.content[0]).toMatchObject({ type: "text", text: "No tracked subsessions." }); }); it("check_subsession scopes by parent and returns the final result", async () => { diff --git a/src/server/sessions/spawnSubsessionTool.ts b/src/server/sessions/spawnSubsessionTool.ts index 6498bf7..1bb0cf1 100644 --- a/src/server/sessions/spawnSubsessionTool.ts +++ b/src/server/sessions/spawnSubsessionTool.ts @@ -78,13 +78,13 @@ const ListSubsessionsParams = Type.Object({}); const CheckSubsessionParams = Type.Object({ sessionId: Type.String({ - description: "Id of a subsession you spawned (as returned by spawn_subsession or list_subsessions).", + description: "Id of a tracked subsession owned by the calling session, as returned by spawn_subsession or list_subsessions.", }), }); const ReadSubsessionParams = Type.Object({ sessionId: Type.String({ - description: "Id of a subsession you spawned (as returned by spawn_subsession or list_subsessions).", + description: "Id of a tracked subsession owned by the calling session, as returned by spawn_subsession or list_subsessions.", }), roles: Type.Optional(Type.Array( Type.Union([Type.Literal("assistant"), Type.Literal("user"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("custom")]), @@ -126,7 +126,7 @@ function renderEntry(entry: TranscriptEntry): string { function clipNotice(part: TranscriptEntry["parts"][number]): string { if ((part.kind === "text" || part.kind === "thinking" || part.kind === "tool_result") && part.truncated !== undefined) { - return ` [+${String(part.truncated.full - part.truncated.shown)} chars truncated; re-read with a larger maxChars]`; + return ` [+${String(part.truncated.full - part.truncated.shown)} chars truncated]`; } return ""; } @@ -153,15 +153,15 @@ function renderTranscript(result: SubsessionReadResult): string { ? "no messages matched your filters" : `no messages in this window (${String(result.matched)} matched outside it)`) : `messages ${String(result.start)}–${String(last.index)} of ${String(result.total)} (${String(result.matched)} matched)`; - const more = result.hasMore ? `\n\nMore matching messages exist earlier; page back with before: ${String(result.start)}.` : ""; + const more = result.hasMore ? `\n\nEarlier matching messages exist before index ${String(result.start)}.` : ""; // Empty entries with matches means the `before` cursor excluded every match // (they all sit at index >= before): the agent paged too far back and should // raise `before` or omit it, not page back further. const body = result.entries.length > 0 ? result.entries.map(renderEntry).join("\n\n") : (result.matched === 0 - ? "(nothing matched; try widening roles/include, dropping search, or raising limit)" - : `(no messages before index ${String(result.start)}; all ${String(result.matched)} matches are later — raise 'before' or omit it)`); + ? "(no messages matched the filters)" + : `(no messages before index ${String(result.start)}; all ${String(result.matched)} matches have later indexes)`); return `Subsession ${result.sessionId} [${result.status}] — ${range}:\n\n${body}${more}`; } @@ -178,7 +178,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse const spawnTool = defineTool({ name: "spawn_subsession", label: "Spawn subsession", - description: "Start an asynchronous tracked child session. The call returns after dispatch. When the child becomes idle or errors, a notification starts a new parent turn or queues behind the current one. Do not poll or sleep while waiting: continue useful independent work, or end this turn normally if blocked. Inspect only when immediately actionable.", + description: "Start a tracked child session and send it an initial prompt. The call returns after dispatch; the parent is notified when the child stops working and can inspect its status, latest output, and transcript.", promptSnippet: "spawn_subsession: start a tracked child session you will be notified about", parameters: SpawnSubsessionParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { @@ -193,7 +193,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse ...(ctx.model === undefined ? {} : { model: ctx.model }), }); return { - content: [{ type: "text", text: `Started subsession ${result.sessionId} in ${result.cwd}. Continue independent work or end this turn if blocked; do not poll. You will be resumed when it stops working.` }], + content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}. The parent will be notified when it stops working.` }], details: result, }; }, @@ -202,7 +202,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse const listTool = defineTool({ name: "list_subsessions", label: "List subsessions", - description: "List the tracked subsessions you spawned, with their current status (working, idle, error, or unknown).", + description: "List tracked child sessions owned by the calling session, with each child's current status (working, idle, error, or unknown).", promptSnippet: "list_subsessions: see the tracked child sessions you spawned", parameters: ListSubsessionsParams, async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { @@ -210,8 +210,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined; const subsessions = await deps.list(parentSessionId, parentSessionFile); const text = subsessions.length === 0 - ? "You have not spawned any subsessions." - : `Your subsessions:\n${subsessions.map(statusLine).join("\n")}`; + ? "No tracked subsessions." + : `Tracked subsessions:\n${subsessions.map(statusLine).join("\n")}`; return { content: [{ type: "text", text }], details: { subsessions } }; }, }); @@ -219,7 +219,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse const checkTool = defineTool({ name: "check_subsession", label: "Check subsession", - description: "Quick glance at a subsession you spawned: its current status and most recent assistant output. Use this to react to what a subsession produced. When the summary is not enough, use read_subsession to look through its full transcript.", + description: "Return a tracked subsession's current status, message count, and most recent assistant output.", promptSnippet: "check_subsession: glance at a subsession's status and latest output", parameters: CheckSubsessionParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { @@ -237,7 +237,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse const readTool = defineTool({ name: "read_subsession", label: "Read subsession", - description: "Read through the transcript of a subsession you spawned. Returns its messages filtered and paginated however you ask: choose which roles (assistant, user, tool, system, custom) and content kinds (text, thinking, tool_call, tool_result, image) to include, search for a substring (always over full content), cap each value's length with maxChars (omit for full text; clipped parts are flagged so truncation is never silent), optionally include raw tool args, and page backward with 'before'/'limit'. Start narrow (e.g. assistant text with a small maxChars) and widen the filters, raise maxChars, or page further back if you don't find what you need. For just the final result, use check_subsession instead.", + description: "Return a filtered, paginated transcript of a tracked subsession. Filters select message roles and content kinds, search full message content, optionally include raw tool arguments, and cap or page the returned entries.", promptSnippet: "read_subsession: read through a subsession's transcript with filters", parameters: ReadSubsessionParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { From bc6718a7e2a0c7667bfcc566d1e737d1fde4df49 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 11 Jul 2026 16:26:41 +0200 Subject: [PATCH 080/111] fix: stabilize mobile message headers --- .changeset/show-complete-message-metadata.md | 2 +- src/client/src/components/shared.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/show-complete-message-metadata.md b/.changeset/show-complete-message-metadata.md index 2eb3305..61723f5 100644 --- a/.changeset/show-complete-message-metadata.md +++ b/.changeset/show-complete-message-metadata.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Show complete chat message dates and model identifiers in one consistent label, wrap rather than truncate expanded metadata, and let the clean touch info control collapse while it retains focus. +Show complete chat message dates and model identifiers in one consistent label, wrap rather than truncate expanded metadata, and keep the touch info control compact without changing message-header height. diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index b0b4045..c8fd3d2 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -327,7 +327,7 @@ export const chatStyles = css` .msg.bash > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-success) 35%, transparent); background: var(--pi-success-bg); } .msg.skill > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-purple-border) 35%, transparent); background: var(--pi-purple-surface); } .group-msg > .msg-header { position: sticky; top: -26px; z-index: 4; margin: -10px 0 8px; padding: 7px 0 6px; border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent); background: var(--pi-bg); } - .msg-header-trailing { min-width: 0; flex: 1 1 auto; display: inline-flex; align-items: baseline; justify-content: flex-end; gap: 8px; } + .msg-header-trailing { min-width: 0; flex: 1 1 auto; display: inline-flex; align-items: center; justify-content: flex-end; gap: 8px; } .msg-actions { flex: 0 0 auto; display: inline-flex; gap: 6px; opacity: 0; transition: opacity .12s ease; } .msg-action { display: inline-grid; place-items: center; width: 24px; height: 24px; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 0; font: 14px system-ui, sans-serif; line-height: 1; cursor: pointer; } .msg-action:hover, .msg-action:focus { color: var(--pi-text); border-color: var(--pi-accent); } @@ -341,7 +341,7 @@ export const chatStyles = css` @media (hover: none) { .msg-actions { opacity: 1; } .msg-meta { opacity: .75; max-width: 26px; } - .msg-meta:not(.expanded) { display: inline-grid; width: 26px; height: 26px; place-items: center; font-size: 0; text-overflow: clip; } + .msg-meta:not(.expanded) { display: inline-grid; width: 26px; height: 22px; place-items: center; font-size: 0; text-overflow: clip; } .msg-meta::before { content: "ⓘ"; font-size: 13px; } .msg-meta.expanded { opacity: 1; max-width: 100%; } .msg-meta.expanded::before { content: ""; } From dfab743a719e9ccecc5296cfcc471314751154e4 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 11 Jul 2026 20:30:51 +0200 Subject: [PATCH 081/111] fix: harden cross-platform release builds Skip direct POSIX entrypoint execution on Windows CI and prevent test-support modules from entering clean build or npm package output. --- package.json | 5 +- src/buildContents.test.ts | 111 +++++++++++++++++++++++ src/docker/piWebDockerEntrypoint.test.ts | 5 +- tsconfig.build.json | 2 +- 4 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 src/buildContents.test.ts diff --git a/package.json b/package.json index b21f6db..8330ea6 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "files": [ "dist", + "!dist/**/*.testSupport.*", "install.sh", "README.md", "LICENSE", @@ -29,7 +30,7 @@ "dev:server": "npm run dev:web", "dev:client": "vite --host 0.0.0.0", "dev:plugins": "node scripts/build-plugins.mjs --watch", - "build": "tsc -p tsconfig.build.json && npm run build:plugin-api && npm run build:plugins && vite build", + "build": "npm run clean && tsc -p tsconfig.build.json && npm run build:plugin-api && npm run build:plugins && vite build", "build:plugin-api": "tsc -p tsconfig.plugin-api.json", "build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs", "capture:screenshots": "node scripts/capture-screenshots.mjs", @@ -40,7 +41,7 @@ "verify": "npm run typecheck && npm run lint && npm run knip && npm test", "start": "tsx src/server/index.ts", "start:sessiond": "tsx src/server/sessiond.ts", - "clean": "rm -rf dist", + "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "prepack": "npm run build", "pack:dry": "npm pack --dry-run", "prepublishOnly": "npm run verify", diff --git a/src/buildContents.test.ts b/src/buildContents.test.ts new file mode 100644 index 0000000..2ce3a53 --- /dev/null +++ b/src/buildContents.test.ts @@ -0,0 +1,111 @@ +import { execFile } from "node:child_process"; +import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +describe("production build contents", () => { + it("keeps test-support modules out of the TypeScript build graph", () => { + const buildConfig = readBuildConfig(); + const program = ts.createProgram({ rootNames: buildConfig.fileNames, options: buildConfig.options }); + const projectSources = program.getSourceFiles() + .map((sourceFile) => normalizePath(relative(repoRoot, sourceFile.fileName))) + .filter((path) => path.startsWith("src/")); + + expect(projectSources).toContain("src/server/app.ts"); + expect(projectSources.filter(isTestSupportPath)).toEqual([]); + }); + + it("keeps test-support artifacts out of the npm tarball", async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), "pi-web-package-contents-")); + try { + const fixtureDist = join(fixtureRoot, "dist", "server"); + await mkdir(fixtureDist, { recursive: true }); + await Promise.all([ + copyFile(join(repoRoot, "package.json"), join(fixtureRoot, "package.json")), + writeFile(join(fixtureDist, "app.js"), "export {};\n", "utf8"), + writeFile(join(fixtureDist, "app.testSupport.js"), "export {};\n", "utf8"), + writeFile(join(fixtureDist, "app.testSupport.js.map"), "{}\n", "utf8"), + ]); + + const npmExecPath = process.env["npm_execpath"]; + if (npmExecPath === undefined || npmExecPath.length === 0) { + throw new Error("npm_execpath is required to verify npm package contents"); + } + const stdout = await execUtf8(process.execPath, [npmExecPath, "pack", "--dry-run", "--json", "--ignore-scripts"], fixtureRoot); + const packagedFiles = packageFilePaths(stdout); + + expect(packagedFiles).toContain("dist/server/app.js"); + expect(packagedFiles.filter(isTestSupportPath)).toEqual([]); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } + }); +}); + +function readBuildConfig(): ts.ParsedCommandLine { + const configPath = join(repoRoot, "tsconfig.build.json"); + const config = ts.getParsedCommandLineOfConfigFile(configPath, {}, { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic(diagnostic) { + throw new Error(formatDiagnostics([diagnostic])); + }, + }); + if (config === undefined) throw new Error(`Unable to parse ${configPath}`); + if (config.errors.length > 0) throw new Error(formatDiagnostics(config.errors)); + return config; +} + +function formatDiagnostics(diagnostics: readonly ts.Diagnostic[]): string { + return ts.formatDiagnostics(diagnostics, { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => repoRoot, + getNewLine: () => "\n", + }); +} + +function normalizePath(path: string): string { + return path.split(sep).join("/"); +} + +function isTestSupportPath(path: string): boolean { + return path.includes(".testSupport."); +} + +function execUtf8(file: string, args: string[], cwd: string): Promise { + return new Promise((resolvePromise, reject) => { + execFile(file, args, { cwd, encoding: "utf8" }, (error, stdout) => { + if (error !== null) { + reject(error instanceof Error ? error : new Error("Command failed")); + return; + } + resolvePromise(stdout); + }); + }); +} + +function packageFilePaths(output: string): string[] { + const parsed: unknown = JSON.parse(output); + if (!Array.isArray(parsed) || parsed.length !== 1) throw new Error("npm pack returned an unexpected result"); + + const packResult: unknown = parsed[0]; + if (!isRecord(packResult)) throw new Error("npm pack result was not an object"); + const filesValue = packResult["files"]; + if (!Array.isArray(filesValue)) throw new Error("npm pack result did not include files"); + const files: unknown[] = filesValue; + + return files.map((file) => { + if (!isRecord(file) || typeof file["path"] !== "string") { + throw new Error("npm pack returned an invalid file entry"); + } + return file["path"]; + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/docker/piWebDockerEntrypoint.test.ts b/src/docker/piWebDockerEntrypoint.test.ts index bd3147d..6d0eeb8 100644 --- a/src/docker/piWebDockerEntrypoint.test.ts +++ b/src/docker/piWebDockerEntrypoint.test.ts @@ -10,7 +10,10 @@ const execFile = promisify(execFileCallback); const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); describe("pi-web-docker entrypoint", () => { - it("streams detached helper logs inline after scheduling runtime updates", async () => { + // The entrypoint intentionally supports POSIX hosts, so Windows CI cannot execute it directly. + const posixHostIt = it.skipIf(process.platform === "win32"); + + posixHostIt("streams detached helper logs inline after scheduling runtime updates", async () => { const tempDir = await mkdtemp(join(tmpdir(), "pi-web-docker-entrypoint-")); try { const runtimeRoot = join(tempDir, "runtime"); diff --git a/tsconfig.build.json b/tsconfig.build.json index 05cc306..1e1e613 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -8,5 +8,5 @@ "sourceMap": true }, "include": ["src/cli.ts", "src/server/**/*.ts"], - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/**/*.testSupport.ts"] } From 6fbeed345e4400cf0b50722c94ce340806482487 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 11 Jul 2026 22:03:31 +0200 Subject: [PATCH 082/111] fix(sessions): clarify subsession join guidance --- .changeset/clarify-subsession-waiting.md | 2 +- docs/config.html | 15 +++++++++++++-- docs/config.md | 4 +++- src/server/sessions/spawnSubsessionTool.test.ts | 13 ++++++------- src/server/sessions/spawnSubsessionTool.ts | 6 +++--- 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.changeset/clarify-subsession-waiting.md b/.changeset/clarify-subsession-waiting.md index 2ebeec3..c76196c 100644 --- a/.changeset/clarify-subsession-waiting.md +++ b/.changeset/clarify-subsession-waiting.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Keep delegation tools available to human-created and independently spawned sessions, remove them from tracked child sessions, and make delegation tool contracts capability-focused. +Keep delegation tools available to human-created and independently spawned sessions, remove them from tracked child sessions, and guide parent agents to track required subsessions and yield at a join point instead of polling. diff --git a/docs/config.html b/docs/config.html index 81a6443..766ea90 100644 --- a/docs/config.html +++ b/docs/config.html @@ -513,8 +513,19 @@ to be enabled.

    - Tracked subsessions let an agent delegate work to child sessions, get notified when children stop - working, and inspect their transcripts. Restart the session daemon after changing this setting. + Tracked subsessions let an agent delegate work to child sessions, receive a notification when each child + stops working, and inspect their status and transcripts. Calling spawn_subsession returns + immediately. The parent can continue independent work while treating every child whose result it needs + as pending. Before producing work that depends on those results, the parent reaches a join point and + yields until every required child has sent a completion notice. +

    +

    + A completion notice wakes an idle parent. If the parent is busy, the notice queues until the current + turn ends rather than interrupting in-flight work. For multiple required children, each notice resolves + one pending child; after processing it, the parent yields again if another required child is pending. + list_subsessions, check_subsession, and read_subsession provide + on-demand status and transcript inspection for deliberate progress checks or recovery. Completion + notifications, rather than polling these tools, are the normal synchronization mechanism.

    In Settings → Session daemon, these keys are saved on the selected machine. Restart the diff --git a/docs/config.md b/docs/config.md index 61553c4..1d8dfee 100644 --- a/docs/config.md +++ b/docs/config.md @@ -171,7 +171,9 @@ The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX `subsessions` is beta and controls whether agents receive the tracked-subsession tools: `spawn_subsession`, `list_subsessions`, `check_subsession`, and `read_subsession`. It defaults to `false` and also requires `spawnSessions` to be enabled. -Tracked subsessions let an agent delegate work to child sessions, get notified when children stop working, and inspect their transcripts. +Tracked subsessions let an agent delegate work to child sessions, receive a notification when each child stops working, and inspect their status and transcripts. Calling `spawn_subsession` returns immediately. The parent can continue independent work while treating every child whose result it needs as pending. Before producing work that depends on those results, the parent reaches a join point and yields until every required child has sent a completion notice. + +A completion notice wakes an idle parent. If the parent is busy, the notice queues until the current turn ends rather than interrupting in-flight work. For multiple required children, each notice resolves one pending child; after processing it, the parent yields again if another required child is pending. `list_subsessions`, `check_subsession`, and `read_subsession` provide on-demand status and transcript inspection for deliberate progress checks or recovery. Completion notifications, rather than polling these tools, are the normal synchronization mechanism. In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them. diff --git a/src/server/sessions/spawnSubsessionTool.test.ts b/src/server/sessions/spawnSubsessionTool.test.ts index c49999b..dd9546a 100644 --- a/src/server/sessions/spawnSubsessionTool.test.ts +++ b/src/server/sessions/spawnSubsessionTool.test.ts @@ -52,26 +52,25 @@ describe("createSubsessionToolDefinitions", () => { expect(firstText(result.content)).toContain("Started tracked subsession child-1"); }); - it("describes tracked dispatch and notification without workflow policy", async () => { + it("guides the parent to join all required subsessions without polling", async () => { const { spawn: spawnTool } = tools({ spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" })), }); - expect(spawnTool.description).toBe("Start a tracked child session and send it an initial prompt. The call returns after dispatch; the parent is notified when the child stops working and can inspect its status, latest output, and transcript."); + expect(spawnTool.description).toBe("Start a tracked child and return after dispatch. Track required children as pending: continue independent work, then yield at a join point until all have notified completion. Notifications queue while the parent is busy; do not poll for completion."); + expect(spawnTool.promptSnippet).toBe("spawn_subsession: delegate parallel work; yield at a join point until all required children complete."); const result = await spawnTool.execute("call-contract", { prompt: "do it" }, undefined, undefined, ctxFor("parent-1", undefined)); - const message = firstText(result.content); - expect(message).toBe("Started tracked subsession child-1 in /repos/a-feature. The parent will be notified when it stops working."); - expect(`${spawnTool.description}\n${message}`).not.toMatch(/do not poll|continue (?:useful|independent) work|end (?:this|the) turn|relay/i); + expect(firstText(result.content)).toBe("Started tracked subsession child-1 in /repos/a-feature. Track it as pending and, before finalizing dependent work, yield until all required children have notified completion."); }); - it("keeps all subsession tool descriptions capability-oriented", () => { + it("keeps subsession inspection tool descriptions capability-oriented", () => { const definitions = tools({}); expect(definitions.list.description).toBe("List tracked child sessions owned by the calling session, with each child's current status (working, idle, error, or unknown)."); expect(definitions.check.description).toBe("Return a tracked subsession's current status, message count, and most recent assistant output."); expect(definitions.read.description).toBe("Return a filtered, paginated transcript of a tracked subsession. Filters select message roles and content kinds, search full message content, optionally include raw tool arguments, and cap or page the returned entries."); - for (const definition of Object.values(definitions)) { + for (const definition of [definitions.list, definitions.check, definitions.read]) { expect(definition.description).not.toMatch(/use this|do not poll|continue working|start narrow|for just the final|relay/i); } }); diff --git a/src/server/sessions/spawnSubsessionTool.ts b/src/server/sessions/spawnSubsessionTool.ts index 1bb0cf1..b3c2a01 100644 --- a/src/server/sessions/spawnSubsessionTool.ts +++ b/src/server/sessions/spawnSubsessionTool.ts @@ -178,8 +178,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse const spawnTool = defineTool({ name: "spawn_subsession", label: "Spawn subsession", - description: "Start a tracked child session and send it an initial prompt. The call returns after dispatch; the parent is notified when the child stops working and can inspect its status, latest output, and transcript.", - promptSnippet: "spawn_subsession: start a tracked child session you will be notified about", + description: "Start a tracked child and return after dispatch. Track required children as pending: continue independent work, then yield at a join point until all have notified completion. Notifications queue while the parent is busy; do not poll for completion.", + promptSnippet: "spawn_subsession: delegate parallel work; yield at a join point until all required children complete.", parameters: SpawnSubsessionParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const parentSessionId = ctx.sessionManager.getSessionId(); @@ -193,7 +193,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse ...(ctx.model === undefined ? {} : { model: ctx.model }), }); return { - content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}. The parent will be notified when it stops working.` }], + content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}. Track it as pending and, before finalizing dependent work, yield until all required children have notified completion.` }], details: result, }; }, From 02f34c495c8315d8d31a1ff36e3850cb9d5e673b Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 11 Jul 2026 22:50:25 +0200 Subject: [PATCH 083/111] feat(terminal): add touch-friendly copy mode --- .changeset/terminal-copy-mode.md | 5 + src/client/src/components/TerminalPanel.ts | 199 +++++++++++- src/client/src/terminalCopySnapshot.test.ts | 249 +++++++++++++++ src/client/src/terminalCopySnapshot.ts | 319 ++++++++++++++++++++ 4 files changed, 766 insertions(+), 6 deletions(-) create mode 100644 .changeset/terminal-copy-mode.md create mode 100644 src/client/src/terminalCopySnapshot.test.ts create mode 100644 src/client/src/terminalCopySnapshot.ts diff --git a/.changeset/terminal-copy-mode.md b/.changeset/terminal-copy-mode.md new file mode 100644 index 0000000..2c99cd8 --- /dev/null +++ b/.changeset/terminal-copy-mode.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a terminal copy mode with a touch-selectable, color-preserving output snapshot and a Copy all action for mobile browsers. diff --git a/src/client/src/components/TerminalPanel.ts b/src/client/src/components/TerminalPanel.ts index 45acf2b..7218189 100644 --- a/src/client/src/components/TerminalPanel.ts +++ b/src/client/src/components/TerminalPanel.ts @@ -1,10 +1,13 @@ import { css, html, LitElement, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; +import { styleMap, type StyleInfo } from "lit/directives/style-map.js"; import { Terminal, type ITerminalOptions, type ITheme } from "@xterm/xterm"; import { FitAddon, type ITerminalDimensions } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import { terminalSocket, terminalsApi, type TerminalCommandRun, type TerminalInfo, type Workspace } from "../api"; +import { writeClipboardText } from "../clipboard"; import { selectFallbackTerminal, selectPreferredTerminal } from "../controllers/terminalSelection"; +import { createTerminalCopySnapshot, DEFAULT_TERMINAL_ANSI_THEME, type TerminalCopyRunStyle, type TerminalCopySnapshot } from "../terminalCopySnapshot"; import { createTerminalSoftKeysDefaultEnvironmentMedia, hasTerminalSoftKeysPreference, initialTerminalSoftKeysEnabled, isTerminalSoftKeysDefaultEnvironment, writeTerminalSoftKeysPreference } from "../terminalSoftKeysPreference"; import "./TerminalSoftKeys"; import type { TerminalSoftKeyInputOptions } from "./TerminalSoftKeys"; @@ -27,6 +30,8 @@ export class TerminalPanel extends LitElement { @property({ type: Boolean }) autoStart = false; @property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined; @query(".terminal-host") private terminalHost?: HTMLDivElement | null; + @query(".terminal-copy-content") private terminalCopyContent?: HTMLPreElement | null; + @query(".terminal-copy-selector") private terminalCopySelector?: HTMLTextAreaElement | null; @state() private terminals: TerminalInfo[] = []; @state() private commandRuns: TerminalCommandRun[] = []; @state() private selectedId: string | undefined; @@ -37,6 +42,8 @@ export class TerminalPanel extends LitElement { @state() private continuingTerminalIds: string[] = []; @state() private defaultSoftKeysEnvironment = false; @state() private softKeysEnabled = initialTerminalSoftKeysEnabled(); + @state() private copySnapshot: TerminalCopySnapshot | undefined; + @state() private copyStatus: string | undefined; private terminal: Terminal | undefined; private fitAddon: FitAddon | undefined; @@ -311,7 +318,7 @@ export class TerminalPanel extends LitElement { this.resizeObserver = new ResizeObserver(() => { this.fitAndNotify(); }); this.resizeObserver.observe(terminalHost); terminal.onData((data) => { - if (this.suppressTerminalInput) return; + if (this.suppressTerminalInput || this.copySnapshot !== undefined) return; this.sendTerminalInput(data); }); const initialSize = this.fitTerminal(); @@ -406,6 +413,7 @@ export class TerminalPanel extends LitElement { } private sendSoftKeyInput(data: string, options: TerminalSoftKeyInputOptions): void { + if (this.copySnapshot !== undefined) return; this.sendTerminalInput(data); if (options.refocus) this.focusTerminal(); } @@ -429,6 +437,8 @@ export class TerminalPanel extends LitElement { this.terminal?.dispose(); this.terminal = undefined; this.fitAddon = undefined; + this.copySnapshot = undefined; + this.copyStatus = undefined; } private renderCommandRunNotice() { @@ -464,20 +474,147 @@ export class TerminalPanel extends LitElement { return null; } + private enterCopyMode(): void { + if (this.copySnapshot !== undefined) return; + this.captureCopySnapshot(); + } + + private refreshCopyMode(): void { + if (this.copySnapshot === undefined) return; + this.captureCopySnapshot(); + } + + private captureCopySnapshot(): void { + const terminal = this.terminal; + if (terminal === undefined) return; + const snapshot = createTerminalCopySnapshot(terminal.buffer.active, terminal.cols, { + theme: terminal.options.theme, + drawBoldTextInBrightColors: terminal.options.drawBoldTextInBrightColors, + }); + this.copySnapshot = snapshot; + this.copyStatus = undefined; + terminal.blur(); + void this.updateComplete.then(() => { + const selector = this.terminalCopySelector; + if (selector === null || selector === undefined) return; + const sourceScrollRange = Math.max(0, snapshot.physicalLineCount - terminal.rows); + const sourceScrollTop = Math.min(sourceScrollRange, snapshot.viewportLine); + const scrollRatio = sourceScrollRange === 0 ? 0 : sourceScrollTop / sourceScrollRange; + selector.scrollTop = scrollRatio * Math.max(0, selector.scrollHeight - selector.clientHeight); + this.syncCopySnapshotScroll(); + }); + } + + private exitCopyMode(): void { + if (this.copySnapshot === undefined) return; + this.copySnapshot = undefined; + this.copyStatus = undefined; + } + + // iOS WebKit offsets native selection hit-testing in a scrolled generic + // overflow container. A textarea owns selection and scrolling while the + // synchronized, noninteractive pre preserves the terminal's ANSI styling. + // Keep its caret visible: iOS hides native selection handles with the caret. + private syncCopySnapshotScroll(): void { + const selector = this.terminalCopySelector; + const content = this.terminalCopyContent; + if (selector === null || selector === undefined || content === null || content === undefined) return; + const selectorVerticalRange = Math.max(0, selector.scrollHeight - selector.clientHeight); + const contentVerticalRange = Math.max(0, content.scrollHeight - content.clientHeight); + const selectorHorizontalRange = Math.max(0, selector.scrollWidth - selector.clientWidth); + const contentHorizontalRange = Math.max(0, content.scrollWidth - content.clientWidth); + content.scrollTop = normalizedScrollOffset(selector.scrollTop, selectorVerticalRange, contentVerticalRange); + content.scrollLeft = normalizedScrollOffset(selector.scrollLeft, selectorHorizontalRange, contentHorizontalRange); + } + + private async copyAllSnapshotText(): Promise { + const text = this.copySnapshot?.text ?? ""; + if (text === "") { + this.copyStatus = "No terminal output to copy."; + return; + } + this.copyStatus = await writeClipboardText(text) ? "Copied all terminal output." : "Unable to copy terminal output."; + } + + private renderCopyModeToggle() { + if (this.selectedId === undefined) return null; + const active = this.copySnapshot !== undefined; + return html` + + `; + } + + private renderCopyModeToolbar() { + const snapshot = this.copySnapshot; + if (snapshot === undefined) return null; + return html` +

    + `; + } + + private renderCopyMode() { + const snapshot = this.copySnapshot; + if (snapshot === undefined) return null; + return html` +
    + ${this.copyToolbarReplacesSoftKeys() ? null : this.renderCopyModeToolbar()} +
    + + +
    +
    + `; + } + private selectedTerminalAcceptsInput(): boolean { const terminal = this.selectedTerminalInfo(); return terminal !== undefined && !terminal.exited; } + private copyToolbarReplacesSoftKeys(): boolean { + return this.copySnapshot !== undefined && this.selectedTerminalAcceptsInput() && this.softKeysEnabled; + } + + private renderTerminalAccessoryBar() { + if (this.copySnapshot !== undefined) return this.copyToolbarReplacesSoftKeys() ? this.renderCopyModeToolbar() : null; + return this.shouldShowSoftKeys() ? this.renderSoftKeys() : null; + } + private shouldShowSoftKeys(): boolean { return this.selectedTerminalAcceptsInput() && this.softKeysEnabled; } private shouldShowSoftKeysToggle(): boolean { - return this.selectedTerminalAcceptsInput(); + return this.copySnapshot === undefined && this.selectedTerminalAcceptsInput(); } private toggleSoftKeys(): void { + if (this.copySnapshot !== undefined) return; this.softKeysEnabled = !this.softKeysEnabled; this.softKeysPreferenceStored = true; writeTerminalSoftKeysPreference(this.softKeysEnabled); @@ -518,6 +655,7 @@ export class TerminalPanel extends LitElement { return html`
    + ${this.renderCopyModeToggle()} ${this.renderSoftKeysToggle()} ${this.terminals.map((terminal) => html`
    ${this.error === undefined ? null : html`

    ${this.error}

    `} ${this.renderCommandRunNotice()} - ${this.shouldShowSoftKeys() ? this.renderSoftKeys() : null} + ${this.renderTerminalAccessoryBar()} ${this.loading ? html`

    Loading terminals…

    ` : null} -
    +
    +
    + ${this.renderCopyMode()} +
    `; } @@ -540,11 +681,19 @@ export class TerminalPanel extends LitElement { :host { flex: 1 1 auto; min-height: 0; display: flex; } .terminal-shell { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--pi-terminal-bg); } .terminal-tabs { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); overflow: auto; } + .terminal-tabs > button { box-sizing: border-box; height: 30px; line-height: 16px; } + /* Desktop xterm already has mouse selection and hardware keys; keep touch controls to touch/narrow layouts. */ + .copy-mode-toggle, .soft-keys-toggle, terminal-soft-keys { display: none; } + .copy-mode-toggle.selected { display: inline-flex; } + @media (pointer: coarse), (max-width: 760px) { + .copy-mode-toggle, .soft-keys-toggle { display: inline-flex; } + terminal-soft-keys { display: block; } + } button { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 180px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; } button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } button.new { flex: 0 0 auto; color: var(--pi-muted); } .soft-keys-toggle { flex: 0 0 auto; } - .soft-keys-toggle .keyboard-icon { flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } + .soft-keys-toggle .keyboard-icon { display: block; flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } button small { color: var(--pi-muted); font-size: 14px; line-height: 1; } button small:hover { color: var(--pi-danger); } @@ -558,7 +707,20 @@ export class TerminalPanel extends LitElement { .command-run-notice code { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-text-secondary); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .command-run-notice kbd { border: 1px solid var(--pi-border); border-radius: 4px; background: var(--pi-bg); padding: 0 4px; font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .command-run-notice button { justify-self: end; max-width: none; } - .terminal-host { flex: 1 1 auto; min-height: 0; padding: 6px; box-sizing: border-box; overflow: hidden; } + .terminal-stage { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; background: var(--pi-terminal-bg); } + .terminal-host { position: absolute; inset: 0; padding: 6px; box-sizing: border-box; overflow: hidden; } + .terminal-host.copying { visibility: hidden; pointer-events: none; } + .terminal-copy-view { position: absolute; inset: 0; display: flex; flex-direction: column; min-height: 0; background: var(--pi-terminal-bg); color: var(--pi-terminal-text); } + .terminal-copy-toolbar { box-sizing: border-box; flex: 0 0 auto; display: flex; align-items: center; gap: 8px; min-width: 0; min-height: 47px; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); color: var(--pi-muted); font: 12px system-ui, sans-serif; } + .terminal-copy-toolbar > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .terminal-copy-toolbar small { margin-left: auto; white-space: nowrap; color: var(--pi-dim); } + .terminal-copy-toolbar button { flex: 0 0 auto; width: auto; min-height: 34px; padding: 6px 9px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + .terminal-copy-layers { flex: 1 1 auto; min-height: 0; display: grid; overflow: hidden; background: var(--pi-terminal-bg); } + /* xterm renders the configured 13px terminal font in 17px-high cells. */ + .terminal-copy-content, .terminal-copy-selector { grid-area: 1 / 1; box-sizing: border-box; min-width: 0; min-height: 0; width: 100%; height: 100%; margin: 0; padding: 6px; border: 0; border-radius: 0; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 17px; letter-spacing: normal; font-variant-ligatures: none; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-all; } + .terminal-copy-content { overflow: auto; pointer-events: none; background: var(--pi-terminal-bg); color: var(--pi-terminal-text); -webkit-user-select: none; user-select: none; } + .terminal-copy-selector { z-index: 1; overflow: auto; resize: none; outline: none; appearance: none; background: transparent; color: transparent; caret-color: var(--pi-accent); -webkit-text-fill-color: transparent; cursor: text; -webkit-user-select: text; user-select: text; -webkit-touch-callout: default; touch-action: auto; } + .terminal-copy-selector::selection { background: var(--pi-terminal-selection); color: transparent; -webkit-text-fill-color: transparent; } .terminal-host .xterm { height: 100%; cursor: text; position: relative; user-select: none; } .terminal-host .xterm.focus, .terminal-host .xterm:focus { outline: none; } .terminal-host .xterm-helpers { position: absolute; top: 0; z-index: 5; } @@ -582,6 +744,30 @@ export class TerminalPanel extends LitElement { `; } +function normalizedScrollOffset(sourceOffset: number, sourceRange: number, targetRange: number): number { + if (sourceRange <= 0 || targetRange <= 0) return 0; + return Math.min(1, Math.max(0, sourceOffset / sourceRange)) * targetRange; +} + +function dimTerminalCopyColor(color: string): string { + return /^#[\da-f]{6}$/i.test(color) ? `${color}80` : `color-mix(in srgb, ${color} 50%, transparent)`; +} + +function terminalCopyRunStyle(style: TerminalCopyRunStyle): StyleInfo { + const decorations = [ + style.underline ? "underline" : undefined, + style.strikethrough ? "line-through" : undefined, + style.overline ? "overline" : undefined, + ].filter((decoration): decoration is string => decoration !== undefined).join(" "); + return { + color: style.invisible ? "transparent" : style.dim ? dimTerminalCopyColor(style.foreground) : style.foreground, + backgroundColor: style.background, + fontWeight: style.bold ? "700" : undefined, + fontStyle: style.italic ? "italic" : undefined, + textDecorationLine: decorations === "" ? undefined : decorations, + }; +} + interface TerminalSize { cols: number; rows: number; @@ -631,6 +817,7 @@ function terminalOptions(element: HTMLElement): ITerminalOptions { function terminalTheme(element: HTMLElement): ITheme { return { + ...DEFAULT_TERMINAL_ANSI_THEME, background: themeColor(element, "--pi-terminal-bg", "#05070a"), foreground: themeColor(element, "--pi-terminal-text", "#e6edf3"), cursor: themeColor(element, "--pi-accent", "#58a6ff"), diff --git a/src/client/src/terminalCopySnapshot.test.ts b/src/client/src/terminalCopySnapshot.test.ts new file mode 100644 index 0000000..ec09383 --- /dev/null +++ b/src/client/src/terminalCopySnapshot.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from "vitest"; +import { + createTerminalCopySnapshot, + type TerminalCopyBufferCellSource, + type TerminalCopyBufferLineSource, + type TerminalCopyBufferSource, +} from "./terminalCopySnapshot"; + +type CellColor = { mode: "default" } | { mode: "palette"; value: number } | { mode: "rgb"; value: number }; + +interface CellOptions { + width?: number; + foreground?: CellColor; + background?: CellColor; + bold?: boolean; + italic?: boolean; + dim?: boolean; + underline?: boolean; + inverse?: boolean; + invisible?: boolean; + strikethrough?: boolean; + overline?: boolean; +} + +class TestCell implements TerminalCopyBufferCellSource { + constructor(private readonly chars: string, private readonly options: CellOptions = {}) {} + + getWidth(): number { return this.options.width ?? 1; } + getChars(): string { return this.chars; } + getCode(): number { return this.chars.codePointAt(0) ?? 0; } + getFgColorMode(): number { return 0; } + getBgColorMode(): number { return 0; } + getFgColor(): number { return colorValue(this.options.foreground); } + getBgColor(): number { return colorValue(this.options.background); } + isBold(): number { return Number(this.options.bold === true); } + isItalic(): number { return Number(this.options.italic === true); } + isDim(): number { return Number(this.options.dim === true); } + isUnderline(): number { return Number(this.options.underline === true); } + isInverse(): number { return Number(this.options.inverse === true); } + isInvisible(): number { return Number(this.options.invisible === true); } + isStrikethrough(): number { return Number(this.options.strikethrough === true); } + isOverline(): number { return Number(this.options.overline === true); } + isFgRGB(): boolean { return this.options.foreground?.mode === "rgb"; } + isBgRGB(): boolean { return this.options.background?.mode === "rgb"; } + isFgPalette(): boolean { return this.options.foreground?.mode === "palette"; } + isBgPalette(): boolean { return this.options.background?.mode === "palette"; } + isFgDefault(): boolean { return this.options.foreground === undefined || this.options.foreground.mode === "default"; } + isBgDefault(): boolean { return this.options.background === undefined || this.options.background.mode === "default"; } + isAttributeDefault(): boolean { + return this.options.foreground === undefined + && this.options.background === undefined + && this.options.bold !== true + && this.options.italic !== true + && this.options.dim !== true + && this.options.underline !== true + && this.options.inverse !== true + && this.options.invisible !== true + && this.options.strikethrough !== true + && this.options.overline !== true; + } +} + +class TestLine implements TerminalCopyBufferLineSource { + readonly length: number; + + constructor(private readonly cells: (TestCell | undefined)[], readonly isWrapped = false) { + this.length = cells.length; + } + + getCell(column: number, cell?: TerminalCopyBufferCellSource): TerminalCopyBufferCellSource | undefined { + void cell; + return this.cells[column]; + } +} + +class TestBuffer implements TerminalCopyBufferSource { + readonly length: number; + + constructor( + private readonly lines: (TestLine | undefined)[], + readonly baseY = 0, + readonly cursorY = Math.max(0, lines.length - 1), + readonly viewportY = baseY, + ) { + this.length = lines.length; + } + + getLine(index: number): TerminalCopyBufferLineSource | undefined { + return this.lines[index]; + } + + getNullCell(): TerminalCopyBufferCellSource { + return new TestCell(""); + } +} + +describe("createTerminalCopySnapshot", () => { + it("joins wrapped physical rows into selectable logical lines", () => { + const buffer = new TestBuffer([ + line("abc"), + line("def", true), + line("next"), + ]); + + const snapshot = createTerminalCopySnapshot(buffer, 20); + + expect(snapshot.lines.map((item) => item.text)).toEqual(["abcdef", "next"]); + expect(snapshot.text).toBe("abcdef\nnext"); + expect(snapshot.physicalLineCount).toBe(3); + }); + + it("preserves palette, RGB, inverse, and text-decoration styles", () => { + const styled = new TestLine([ + new TestCell("A", { foreground: { mode: "palette", value: 1 }, bold: true }), + new TestCell("B", { foreground: { mode: "palette", value: 1 }, bold: true }), + new TestCell("C", { + foreground: { mode: "rgb", value: 0x123456 }, + background: { mode: "palette", value: 4 }, + italic: true, + underline: true, + strikethrough: true, + overline: true, + }), + new TestCell("D", { + foreground: { mode: "palette", value: 2 }, + background: { mode: "rgb", value: 0x010203 }, + inverse: true, + }), + ]); + + const snapshot = createTerminalCopySnapshot(new TestBuffer([styled]), 20); + + expect(snapshot.lines[0]?.runs).toHaveLength(3); + expect(snapshot.lines[0]?.runs[0]).toMatchObject({ + text: "AB", + style: { foreground: "#ef2929", background: "#000000", bold: true }, + }); + expect(snapshot.lines[0]?.runs[1]).toMatchObject({ + text: "C", + style: { + foreground: "#123456", + background: "#3465a4", + italic: true, + underline: true, + strikethrough: true, + overline: true, + }, + }); + expect(snapshot.lines[0]?.runs[2]).toMatchObject({ + text: "D", + style: { foreground: "#010203", background: "#4e9a06" }, + }); + }); + + it("uses terminal theme colors and extended ANSI overrides", () => { + const source = new TestLine([ + new TestCell("A"), + new TestCell("B", { foreground: { mode: "palette", value: 1 } }), + new TestCell("C", { foreground: { mode: "palette", value: 16 } }), + ]); + + const snapshot = createTerminalCopySnapshot(new TestBuffer([source]), 20, { + theme: { + foreground: "#eeeeee", + background: "#111111", + red: "#aa0000", + extendedAnsi: ["#abcdef"], + }, + }); + + expect(snapshot.lines[0]?.runs.map((run) => [run.text, run.style.foreground, run.style.background])).toEqual([ + ["A", "#eeeeee", "#111111"], + ["B", "#aa0000", "#111111"], + ["C", "#abcdef", "#111111"], + ]); + }); + + it("keeps interior blanks while trimming unused cells on the right", () => { + const source = new TestLine([ + new TestCell("A"), + new TestCell(""), + new TestCell("B"), + new TestCell(""), + new TestCell(""), + ]); + + const snapshot = createTerminalCopySnapshot(new TestBuffer([source]), 5); + + expect(snapshot.text).toBe("A B"); + }); + + it("includes the cursor line but omits unused rows below it", () => { + const buffer = new TestBuffer([ + line("output"), + new TestLine([new TestCell("")]), + new TestLine([new TestCell("")]), + ], 0, 1, 1); + + const snapshot = createTerminalCopySnapshot(buffer, 20); + + expect(snapshot.lines.map((item) => item.text)).toEqual(["output", ""]); + expect(snapshot.text).toBe("output\n"); + expect(snapshot.physicalLineCount).toBe(2); + expect(snapshot.viewportLine).toBe(1); + }); + + it("preserves wide and combined cells while skipping continuation cells", () => { + const source = new TestLine([ + new TestCell("👩‍💻", { width: 2 }), + new TestCell("", { width: 0 }), + new TestCell("é"), + new TestCell("!"), + ]); + + const snapshot = createTerminalCopySnapshot(new TestBuffer([source]), 10); + + expect(snapshot.text).toBe("👩‍💻é!"); + }); + + it("respects disabled bold-to-bright color promotion and terminal column bounds", () => { + const source = new TestLine([ + new TestCell("A", { foreground: { mode: "palette", value: 1 }, bold: true }), + new TestCell("B"), + new TestCell("C"), + ]); + + const snapshot = createTerminalCopySnapshot(new TestBuffer([source]), 2, { drawBoldTextInBrightColors: false }); + + expect(snapshot.text).toBe("AB"); + expect(snapshot.lines[0]?.runs[0]?.style.foreground).toBe("#cc0000"); + }); + + it("returns an empty snapshot when there are no usable columns", () => { + expect(createTerminalCopySnapshot(new TestBuffer([line("output")]), 0)).toEqual({ + text: "", + lines: [], + physicalLineCount: 0, + viewportLine: 0, + }); + }); +}); + +function line(text: string, isWrapped = false): TestLine { + return new TestLine(Array.from(text, (character) => new TestCell(character)), isWrapped); +} + +function colorValue(color: CellColor | undefined): number { + return color?.mode === "default" || color === undefined ? 0 : color.value; +} diff --git a/src/client/src/terminalCopySnapshot.ts b/src/client/src/terminalCopySnapshot.ts new file mode 100644 index 0000000..24c4f46 --- /dev/null +++ b/src/client/src/terminalCopySnapshot.ts @@ -0,0 +1,319 @@ +import type { ITheme } from "@xterm/xterm"; + +export interface TerminalCopyBufferSource { + readonly baseY: number; + readonly cursorY: number; + readonly viewportY: number; + readonly length: number; + getLine(index: number): TerminalCopyBufferLineSource | undefined; + getNullCell(): TerminalCopyBufferCellSource; +} + +export interface TerminalCopyBufferLineSource { + readonly isWrapped: boolean; + readonly length: number; + getCell(column: number, cell?: TerminalCopyBufferCellSource): TerminalCopyBufferCellSource | undefined; +} + +export interface TerminalCopyBufferCellSource { + getWidth(): number; + getChars(): string; + getCode(): number; + getFgColorMode(): number; + getBgColorMode(): number; + getFgColor(): number; + getBgColor(): number; + isBold(): number; + isItalic(): number; + isDim(): number; + isUnderline(): number; + isInverse(): number; + isInvisible(): number; + isStrikethrough(): number; + isOverline(): number; + isFgRGB(): boolean; + isBgRGB(): boolean; + isFgPalette(): boolean; + isBgPalette(): boolean; + isFgDefault(): boolean; + isBgDefault(): boolean; + isAttributeDefault(): boolean; +} + +export interface TerminalCopyRunStyle { + foreground: string; + background: string; + bold: boolean; + italic: boolean; + dim: boolean; + underline: boolean; + invisible: boolean; + strikethrough: boolean; + overline: boolean; +} + +export interface TerminalCopyRun { + text: string; + style: TerminalCopyRunStyle; +} + +export interface TerminalCopyLine { + text: string; + runs: TerminalCopyRun[]; +} + +export interface TerminalCopySnapshot { + text: string; + lines: TerminalCopyLine[]; + physicalLineCount: number; + viewportLine: number; +} + +export interface TerminalCopySnapshotOptions { + theme?: ITheme | undefined; + drawBoldTextInBrightColors?: boolean | undefined; +} + +interface CapturedPhysicalLine extends TerminalCopyLine { + wrapped: boolean; +} + +const DEFAULT_FOREGROUND = "#ffffff"; +const DEFAULT_BACKGROUND = "#000000"; +const DEFAULT_ANSI_COLORS = [ + "#2e3436", "#cc0000", "#4e9a06", "#c4a000", "#3465a4", "#75507b", "#06989a", "#d3d7cf", + "#555753", "#ef2929", "#8ae234", "#fce94f", "#729fcf", "#ad7fa8", "#34e2e2", "#eeeeec", +] as const; +const ANSI_THEME_KEYS = [ + "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", + "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite", +] as const satisfies readonly (keyof ITheme)[]; + +// Pin the palette used by both xterm and its copy snapshot so a dependency +// upgrade cannot make the interactive and selectable views drift apart. +export const DEFAULT_TERMINAL_ANSI_THEME: ITheme = { + black: DEFAULT_ANSI_COLORS[0], + red: DEFAULT_ANSI_COLORS[1], + green: DEFAULT_ANSI_COLORS[2], + yellow: DEFAULT_ANSI_COLORS[3], + blue: DEFAULT_ANSI_COLORS[4], + magenta: DEFAULT_ANSI_COLORS[5], + cyan: DEFAULT_ANSI_COLORS[6], + white: DEFAULT_ANSI_COLORS[7], + brightBlack: DEFAULT_ANSI_COLORS[8], + brightRed: DEFAULT_ANSI_COLORS[9], + brightGreen: DEFAULT_ANSI_COLORS[10], + brightYellow: DEFAULT_ANSI_COLORS[11], + brightBlue: DEFAULT_ANSI_COLORS[12], + brightMagenta: DEFAULT_ANSI_COLORS[13], + brightCyan: DEFAULT_ANSI_COLORS[14], + brightWhite: DEFAULT_ANSI_COLORS[15], +}; + +export function createTerminalCopySnapshot( + buffer: TerminalCopyBufferSource, + columns: number, + options: TerminalCopySnapshotOptions = {}, +): TerminalCopySnapshot { + const columnCount = Math.max(0, Math.floor(columns)); + if (buffer.length <= 0 || columnCount === 0) return { text: "", lines: [], physicalLineCount: 0, viewportLine: 0 }; + + const palette = terminalAnsiPalette(options.theme); + const foreground = options.theme?.foreground ?? DEFAULT_FOREGROUND; + const background = options.theme?.background ?? DEFAULT_BACKGROUND; + const physicalLines: CapturedPhysicalLine[] = []; + const reusableCell = buffer.getNullCell(); + let lastMeaningfulLine = -1; + + for (let index = 0; index < buffer.length; index += 1) { + const sourceLine = buffer.getLine(index); + if (sourceLine === undefined) { + physicalLines.push({ text: "", runs: [], wrapped: false }); + continue; + } + const line = capturePhysicalLine(sourceLine, columnCount, reusableCell, { + palette, + foreground, + background, + drawBoldTextInBrightColors: options.drawBoldTextInBrightColors !== false, + }); + physicalLines.push(line); + if (line.text !== "" || line.runs.some((run) => run.style.background !== background)) lastMeaningfulLine = index; + } + + const cursorLine = Math.min(buffer.length - 1, Math.max(0, buffer.baseY + buffer.cursorY)); + const endLine = Math.max(lastMeaningfulLine, cursorLine); + const includedPhysicalLines = physicalLines.slice(0, endLine + 1); + const lines: TerminalCopyLine[] = []; + + for (const physicalLine of includedPhysicalLines) { + const currentLine = lines.at(-1); + if (physicalLine.wrapped && currentLine !== undefined) { + currentLine.text += physicalLine.text; + appendRuns(currentLine.runs, physicalLine.runs); + continue; + } + lines.push({ text: physicalLine.text, runs: physicalLine.runs.map((run) => ({ text: run.text, style: run.style })) }); + } + + return { + text: lines.map((line) => line.text).join("\n"), + lines, + physicalLineCount: includedPhysicalLines.length, + viewportLine: Math.min(endLine, Math.max(0, buffer.viewportY)), + }; +} + +interface CaptureColors { + palette: readonly string[]; + foreground: string; + background: string; + drawBoldTextInBrightColors: boolean; +} + +function capturePhysicalLine(sourceLine: TerminalCopyBufferLineSource, columns: number, reusableCell: TerminalCopyBufferCellSource, colors: CaptureColors): CapturedPhysicalLine { + const cells: { text: string; meaningful: boolean; style: TerminalCopyRunStyle }[] = []; + const cellCount = Math.min(columns, sourceLine.length); + + for (let column = 0; column < cellCount; column += 1) { + const cell = sourceLine.getCell(column, reusableCell); + if (cell === undefined) { + cells.push({ text: " ", meaningful: false, style: defaultRunStyle(colors) }); + continue; + } + const width = cell.getWidth(); + if (width === 0) continue; + const chars = cell.getChars(); + cells.push({ + text: chars === "" ? " ".repeat(Math.max(1, width)) : chars, + meaningful: chars !== "" || !cell.isAttributeDefault(), + style: copyRunStyle(cell, colors), + }); + } + + let lastMeaningfulCell = cells.length - 1; + while (lastMeaningfulCell >= 0 && cells[lastMeaningfulCell]?.meaningful !== true) lastMeaningfulCell -= 1; + + const runs: TerminalCopyRun[] = []; + let text = ""; + for (let index = 0; index <= lastMeaningfulCell; index += 1) { + const cell = cells[index]; + if (cell === undefined) continue; + text += cell.text; + appendRun(runs, { text: cell.text, style: cell.style }); + } + + return { text, runs, wrapped: sourceLine.isWrapped }; +} + +function copyRunStyle(cell: TerminalCopyBufferCellSource, colors: CaptureColors): TerminalCopyRunStyle { + const inverse = cell.isInverse() !== 0; + let foreground = resolveCellColor(cell, "foreground", colors); + let background = resolveCellColor(cell, "background", colors); + if (inverse) [foreground, background] = [background, foreground]; + + if (cell.isBold() !== 0 && colors.drawBoldTextInBrightColors) { + const foregroundPaletteIndex = inverse + ? cell.isBgPalette() ? cell.getBgColor() : undefined + : cell.isFgPalette() ? cell.getFgColor() : undefined; + if (foregroundPaletteIndex !== undefined && foregroundPaletteIndex >= 0 && foregroundPaletteIndex < 8) { + foreground = colors.palette[foregroundPaletteIndex + 8] ?? foreground; + } + } + + return { + foreground, + background, + bold: cell.isBold() !== 0, + italic: cell.isItalic() !== 0, + dim: cell.isDim() !== 0, + underline: cell.isUnderline() !== 0, + invisible: cell.isInvisible() !== 0, + strikethrough: cell.isStrikethrough() !== 0, + overline: cell.isOverline() !== 0, + }; +} + +function resolveCellColor(cell: TerminalCopyBufferCellSource, target: "foreground" | "background", colors: CaptureColors): string { + const rgb = target === "foreground" ? cell.isFgRGB() : cell.isBgRGB(); + const palette = target === "foreground" ? cell.isFgPalette() : cell.isBgPalette(); + const value = target === "foreground" ? cell.getFgColor() : cell.getBgColor(); + if (rgb) return rgbColor(value); + if (palette) return colors.palette[value] ?? (target === "foreground" ? colors.foreground : colors.background); + return target === "foreground" ? colors.foreground : colors.background; +} + +function defaultRunStyle(colors: CaptureColors): TerminalCopyRunStyle { + return { + foreground: colors.foreground, + background: colors.background, + bold: false, + italic: false, + dim: false, + underline: false, + invisible: false, + strikethrough: false, + overline: false, + }; +} + +function appendRuns(target: TerminalCopyRun[], incoming: readonly TerminalCopyRun[]): void { + for (const run of incoming) appendRun(target, run); +} + +function appendRun(runs: TerminalCopyRun[], run: TerminalCopyRun): void { + if (run.text === "") return; + const previous = runs.at(-1); + if (previous !== undefined && sameRunStyle(previous.style, run.style)) { + previous.text += run.text; + return; + } + runs.push({ text: run.text, style: run.style }); +} + +function sameRunStyle(left: TerminalCopyRunStyle, right: TerminalCopyRunStyle): boolean { + return left.foreground === right.foreground + && left.background === right.background + && left.bold === right.bold + && left.italic === right.italic + && left.dim === right.dim + && left.underline === right.underline + && left.invisible === right.invisible + && left.strikethrough === right.strikethrough + && left.overline === right.overline; +} + +function terminalAnsiPalette(theme: ITheme | undefined): string[] { + const colors: string[] = [...DEFAULT_ANSI_COLORS]; + for (let index = 0; index < ANSI_THEME_KEYS.length; index += 1) { + const key = ANSI_THEME_KEYS[index]; + if (key === undefined) continue; + const themedColor = theme?.[key]; + if (typeof themedColor === "string") colors[index] = themedColor; + } + + const levels = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]; + for (let index = 0; index < 216; index += 1) { + const red = levels[Math.floor(index / 36) % 6] ?? 0; + const green = levels[Math.floor(index / 6) % 6] ?? 0; + const blue = levels[index % 6] ?? 0; + colors.push(rgbChannels(red, green, blue)); + } + for (let index = 0; index < 24; index += 1) { + const channel = 8 + index * 10; + colors.push(rgbChannels(channel, channel, channel)); + } + for (let index = 0; index < Math.min(theme?.extendedAnsi?.length ?? 0, 240); index += 1) { + const themedColor = theme?.extendedAnsi?.[index]; + if (themedColor !== undefined) colors[index + 16] = themedColor; + } + return colors; +} + +function rgbColor(value: number): string { + return `#${(value & 0xFFFFFF).toString(16).padStart(6, "0")}`; +} + +function rgbChannels(red: number, green: number, blue: number): string { + return `#${red.toString(16).padStart(2, "0")}${green.toString(16).padStart(2, "0")}${blue.toString(16).padStart(2, "0")}`; +} From 338faf4b811e7b0ec126d8c1aaf1806e1fc7736a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 09:22:19 +0200 Subject: [PATCH 084/111] perf: speed up chat loading and resume --- .changeset/faster-chat-loading.md | 5 + package-lock.json | 276 +++++++++++++++++- package.json | 1 + .../appShell/browserResumeController.test.ts | 135 +++++++++ .../src/appShell/browserResumeController.ts | 98 +++++++ src/client/src/components/ChatView.test.ts | 188 +++++++++++- src/client/src/components/ChatView.ts | 28 +- src/client/src/components/PiWebApp.ts | 44 +-- .../controllers/activityController.test.ts | 43 +++ .../src/controllers/activityController.ts | 8 +- .../sessionController.refresh.test.ts | 96 ++++++ .../src/controllers/sessionController.ts | 64 +++- .../controllers/trailingRefreshCoordinator.ts | 57 ++++ src/server/app.compression.test.ts | 90 ++++++ src/server/app.ts | 8 + src/server/browserMessageProjection.test.ts | 64 ++++ src/server/browserMessageProjection.ts | 59 ++++ src/server/machines/machineClient.test.ts | 32 ++ src/server/machines/machineClient.ts | 30 +- src/server/realtime/sessionEventHub.test.ts | 16 + src/server/realtime/sessionEventHub.ts | 3 +- .../piSessionService.lifecycle.test.ts | 164 ++++++++++- src/server/sessions/piSessionService.ts | 102 ++++++- src/server/sessions/sessionRoutes.test.ts | 33 ++- src/server/sessions/sessionRoutes.ts | 4 +- 25 files changed, 1565 insertions(+), 83 deletions(-) create mode 100644 .changeset/faster-chat-loading.md create mode 100644 src/client/src/appShell/browserResumeController.test.ts create mode 100644 src/client/src/appShell/browserResumeController.ts create mode 100644 src/client/src/controllers/sessionController.refresh.test.ts create mode 100644 src/client/src/controllers/trailingRefreshCoordinator.ts create mode 100644 src/server/app.compression.test.ts create mode 100644 src/server/browserMessageProjection.test.ts create mode 100644 src/server/browserMessageProjection.ts diff --git a/.changeset/faster-chat-loading.md b/.changeset/faster-chat-loading.md new file mode 100644 index 0000000..826311a --- /dev/null +++ b/.changeset/faster-chat-loading.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Improve chat loading and resume performance by sharing duplicate session work, compressing browser responses, trimming unused thinking signatures, and lazily rendering closed technical-event groups. diff --git a/package-lock.json b/package-lock.json index a30b33c..8a79114 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@codemirror/legacy-modes": "^6.5.3", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.6", + "@fastify/compress": "^9.0.0", "@fastify/static": "^9.3.0", "@fastify/websocket": "^11.3.0", "@xterm/addon-fit": "^0.11.0", @@ -3656,6 +3657,62 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@fastify/compress": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@fastify/compress/-/compress-9.0.0.tgz", + "integrity": "sha512-PZRg+ut5xd/ubsGPWfoPNryoCOtEdHboIWpDieTUHov1gKdLitF8mRmT3JbqNnRbelQXSNXUsIpakAEKR6AcTQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "fastify-plugin": "^5.0.0", + "mime-db": "^1.52.0", + "minipass": "^7.0.4", + "peek-stream": "^1.1.3", + "readable-stream": "^4.5.2" + } + }, + "node_modules/@fastify/compress/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/compress/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -5835,6 +5892,18 @@ "addons/*" ] }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -6019,7 +6088,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -6091,6 +6159,30 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -6098,6 +6190,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -6148,6 +6246,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -6631,6 +6735,24 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -7315,6 +7437,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -7406,6 +7548,12 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -8054,6 +8202,15 @@ "node": ">=10.0.0" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -8471,6 +8628,59 @@ "dev": true, "license": "MIT" }, + "node_modules/peek-stream": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/peek-stream/-/peek-stream-1.1.3.tgz", + "integrity": "sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "duplexify": "^3.5.0", + "through2": "^2.0.3" + } + }, + "node_modules/peek-stream/node_modules/duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "node_modules/peek-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/peek-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/peek-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8593,6 +8803,21 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -9203,6 +9428,46 @@ "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", "license": "MIT" }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/through2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/through2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -9688,6 +9953,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/package.json b/package.json index 8330ea6..115719b 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@codemirror/legacy-modes": "^6.5.3", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.6", + "@fastify/compress": "^9.0.0", "@fastify/static": "^9.3.0", "@fastify/websocket": "^11.3.0", "@xterm/addon-fit": "^0.11.0", diff --git a/src/client/src/appShell/browserResumeController.test.ts b/src/client/src/appShell/browserResumeController.test.ts new file mode 100644 index 0000000..e5ff3b0 --- /dev/null +++ b/src/client/src/appShell/browserResumeController.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from "vitest"; +import { BrowserResumeController } from "./browserResumeController"; + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolveDeferred: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { resolveDeferred = resolve; }); + if (resolveDeferred === undefined) throw new Error("Deferred promise was not initialized"); + return { promise, resolve: resolveDeferred }; +} + +function frameHarness() { + const frames: { callback: () => void; canceled: boolean }[] = []; + return { + scheduleFrame: (callback: () => void) => { + const frame = { callback, canceled: false }; + frames.push(frame); + return { cancel: () => { frame.canceled = true; } }; + }, + pendingCount: () => frames.filter((frame) => !frame.canceled).length, + runNext: () => { + const frame = frames.shift(); + if (frame === undefined) throw new Error("No scheduled frame"); + if (!frame.canceled) frame.callback(); + }, + }; +} + +describe("BrowserResumeController", () => { + it("batches overlapping focus and visible signals into one app refresh", async () => { + const windowTarget = new EventTarget(); + const documentTarget = new EventTarget(); + const frames = frameHarness(); + const refreshGate = deferred(); + const refreshStarted = deferred(); + const refreshCompleted = deferred(); + const onResumeSignal = vi.fn(); + let visible = true; + let refreshCalls = 0; + const controller = new BrowserResumeController({ + onResumeSignal, + refreshAfterResume: async () => { + refreshCalls += 1; + refreshStarted.resolve(undefined); + await refreshGate.promise; + refreshCompleted.resolve(undefined); + }, + onRefreshError: (error) => { throw error; }, + }, { + windowTarget, + documentTarget, + isDocumentVisible: () => visible, + scheduleFrame: frames.scheduleFrame, + }); + controller.connect(); + + windowTarget.dispatchEvent(new Event("focus")); + documentTarget.dispatchEvent(new Event("visibilitychange")); + windowTarget.dispatchEvent(new Event("focus")); + + expect(onResumeSignal).toHaveBeenCalledTimes(3); + expect(frames.pendingCount()).toBe(1); + expect(refreshCalls).toBe(0); + + frames.runNext(); + await refreshStarted.promise; + expect(refreshCalls).toBe(1); + + visible = false; + documentTarget.dispatchEvent(new Event("visibilitychange")); + expect(onResumeSignal).toHaveBeenCalledTimes(3); + expect(frames.pendingCount()).toBe(0); + + refreshGate.resolve(undefined); + await refreshCompleted.promise; + windowTarget.dispatchEvent(new Event("focus")); + expect(frames.pendingCount()).toBe(1); + controller.disconnect(); + frames.runNext(); + await Promise.resolve(); + windowTarget.dispatchEvent(new Event("focus")); + expect(onResumeSignal).toHaveBeenCalledTimes(4); + expect(refreshCalls).toBe(1); + }); + + it("runs one trailing refresh when another resume arrives during active work", async () => { + const windowTarget = new EventTarget(); + const documentTarget = new EventTarget(); + const frames = frameHarness(); + const firstGate = deferred(); + const secondGate = deferred(); + const firstStarted = deferred(); + const secondStarted = deferred(); + const secondCompleted = deferred(); + let refreshCalls = 0; + const controller = new BrowserResumeController({ + onResumeSignal: () => undefined, + refreshAfterResume: async () => { + refreshCalls += 1; + if (refreshCalls === 1) { + firstStarted.resolve(undefined); + await firstGate.promise; + return; + } + secondStarted.resolve(undefined); + await secondGate.promise; + secondCompleted.resolve(undefined); + }, + onRefreshError: (error) => { throw error; }, + }, { + windowTarget, + documentTarget, + isDocumentVisible: () => true, + scheduleFrame: frames.scheduleFrame, + }); + controller.connect(); + + windowTarget.dispatchEvent(new Event("focus")); + frames.runNext(); + await firstStarted.promise; + + documentTarget.dispatchEvent(new Event("visibilitychange")); + windowTarget.dispatchEvent(new Event("focus")); + expect(frames.pendingCount()).toBe(1); + frames.runNext(); + expect(refreshCalls).toBe(1); + + firstGate.resolve(undefined); + await secondStarted.promise; + expect(refreshCalls).toBe(2); + + secondGate.resolve(undefined); + await secondCompleted.promise; + controller.disconnect(); + }); +}); diff --git a/src/client/src/appShell/browserResumeController.ts b/src/client/src/appShell/browserResumeController.ts new file mode 100644 index 0000000..638c1bf --- /dev/null +++ b/src/client/src/appShell/browserResumeController.ts @@ -0,0 +1,98 @@ +import { TrailingRefreshCoordinator } from "../controllers/trailingRefreshCoordinator"; + +interface BrowserEventTarget { + addEventListener(type: string, listener: EventListener): void; + removeEventListener(type: string, listener: EventListener): void; +} + +interface ScheduledFrame { + cancel(): void; +} + +export interface BrowserResumeCallbacks { + onResumeSignal(): void; + refreshAfterResume(): void | Promise; + onRefreshError(error: unknown): void; +} + +export interface BrowserResumeControllerOptions { + windowTarget?: BrowserEventTarget | undefined; + documentTarget?: BrowserEventTarget | undefined; + isDocumentVisible?: (() => boolean) | undefined; + scheduleFrame?: ((callback: () => void) => ScheduledFrame) | undefined; +} + +/** Owns browser resume listeners and batches focus/visibility refreshes per frame. */ +export class BrowserResumeController { + private readonly windowTarget: BrowserEventTarget | undefined; + private readonly documentTarget: BrowserEventTarget | undefined; + private readonly isDocumentVisible: () => boolean; + private readonly scheduleFrame: (callback: () => void) => ScheduledFrame; + private readonly refreshes = new TrailingRefreshCoordinator<"browser-resume">(); + private scheduledRefresh: ScheduledFrame | undefined; + private connected = false; + + constructor(private readonly callbacks: BrowserResumeCallbacks, options: BrowserResumeControllerOptions = {}) { + this.windowTarget = options.windowTarget ?? browserWindowTarget(); + this.documentTarget = options.documentTarget ?? browserDocumentTarget(); + this.isDocumentVisible = options.isDocumentVisible ?? documentIsVisible; + this.scheduleFrame = options.scheduleFrame ?? scheduleBrowserFrame; + } + + connect(): void { + if (this.connected) return; + this.connected = true; + this.windowTarget?.addEventListener("focus", this.onFocus); + this.documentTarget?.addEventListener("visibilitychange", this.onVisibilityChange); + } + + disconnect(): void { + if (!this.connected) return; + this.connected = false; + this.windowTarget?.removeEventListener("focus", this.onFocus); + this.documentTarget?.removeEventListener("visibilitychange", this.onVisibilityChange); + this.scheduledRefresh?.cancel(); + this.scheduledRefresh = undefined; + } + + private readonly onFocus: EventListener = () => { + this.handleResumeSignal(); + }; + + private readonly onVisibilityChange: EventListener = () => { + if (this.isDocumentVisible()) this.handleResumeSignal(); + }; + + private handleResumeSignal(): void { + this.callbacks.onResumeSignal(); + if (this.scheduledRefresh !== undefined) return; + this.scheduledRefresh = this.scheduleFrame(() => { + this.scheduledRefresh = undefined; + if (!this.connected) return; + void this.refreshes.request("browser-resume", async () => { + if (this.connected) await this.callbacks.refreshAfterResume(); + }).catch((error: unknown) => { this.callbacks.onRefreshError(error); }); + }); + } +} + +function browserWindowTarget(): BrowserEventTarget | undefined { + return typeof window === "undefined" ? undefined : window; +} + +function browserDocumentTarget(): BrowserEventTarget | undefined { + return typeof document === "undefined" ? undefined : document; +} + +function documentIsVisible(): boolean { + return typeof document === "undefined" || document.visibilityState === "visible"; +} + +function scheduleBrowserFrame(callback: () => void): ScheduledFrame { + if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { + const frame = window.requestAnimationFrame(() => { callback(); }); + return { cancel: () => { window.cancelAnimationFrame(frame); } }; + } + const timer = globalThis.setTimeout(callback, 0); + return { cancel: () => { globalThis.clearTimeout(timer); } }; +} diff --git a/src/client/src/components/ChatView.test.ts b/src/client/src/components/ChatView.test.ts index 3aa8484..df3b167 100644 --- a/src/client/src/components/ChatView.test.ts +++ b/src/client/src/components/ChatView.test.ts @@ -1,5 +1,7 @@ +import type { TemplateResult } from "lit"; import { describe, expect, it } from "vitest"; -import { chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView"; +import type { ChatLine } from "./shared"; +import { ChatView, chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView"; describe("chatQueuedMessageSections", () => { it("labels client-side pending-start sends separately from server queued messages", () => { @@ -35,3 +37,187 @@ describe("chatMessageMetadataLabel", () => { })).toBe(`${formattedTimestamp} · provider/model`); }); }); + +describe("ChatView technical-event groups", () => { + const messages: ChatLine[] = [ + { role: "assistant", parts: [{ type: "toolCall", toolName: "read", summary: "inspect a file" }] }, + { role: "tool", parts: [{ type: "toolExecution", toolName: "read", summary: "inspect a file", status: "success", resultText: "large result" }] }, + ]; + + it("defers a closed body while retaining native disclosure and group scroll anchors", () => { + const view = new ChatView(); + view.sessionId = "session-1"; + const bodyCalls = observeGroupBodyRenders(view); + + const closed = renderMessageGroup(view, messages, 40, 41, false); + + expect(bodyCalls).toEqual([]); + expect(templateStaticMarkup(closed)).toContain(""); + expect(templateStaticMarkup(closed)).toContain('aria-hidden="true"'); + expect(templateValuesAfterMarker(closed, "?open=")).toEqual([false]); + expect(templateValuesAfterMarker(closed, "data-scroll-anchor-id=")).toEqual(["g:40"]); + expect(templateValuesAfterMarker(closed, "data-marker-id=")).toEqual(["g:41"]); + }); + + // Direct handler extraction keeps this node-environment test focused on the + // native details toggle wiring without introducing a component-wide DOM shim. + it("renders an opened body with event anchors and removes it when closed again", () => { + const view = new ChatView(); + view.sessionId = "session-1"; + const bodyCalls = observeGroupBodyRenders(view); + const initiallyClosed = renderMessageGroup(view, messages, 40, 41, false); + + dispatchDetailsToggle(templateEventHandler(initiallyClosed, "@toggle="), true); + const opened = renderMessageGroup(view, messages, 40, 41, false); + + expect(bodyCalls).toEqual([{ messages, startIndex: 40 }]); + expect(templateValuesAfterMarker(opened, "?open=")).toEqual([true]); + expect(templateValuesAfterMarker(opened, "data-scroll-anchor-id=")).toEqual(["g:40", "e:40", "e:41"]); + + bodyCalls.length = 0; + dispatchDetailsToggle(templateEventHandler(opened, "@toggle="), false); + const closedAgain = renderMessageGroup(view, messages, 40, 41, false); + + expect(bodyCalls).toEqual([]); + expect(templateValuesAfterMarker(closedAgain, "?open=")).toEqual([false]); + expect(templateValuesAfterMarker(closedAgain, "data-scroll-anchor-id=")).toEqual(["g:40"]); + }); + + it("renders a live tail body by default", () => { + const view = new ChatView(); + view.sessionId = "session-1"; + const bodyCalls = observeGroupBodyRenders(view); + + const live = renderMessageGroup(view, messages, 40, 41, true); + + expect(bodyCalls).toEqual([{ messages, startIndex: 40 }]); + expect(templateValuesAfterMarker(live, "?open=")).toEqual([true]); + expect(templateValues(live)).toContain("msg event-group live"); + expect(templateValues(live)).toContain("live events"); + }); +}); + +interface GroupBodyRenderCall { + messages: ChatLine[]; + startIndex: number; +} + +type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult; +type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult; +type TemplateEventHandler = (event: Event) => void; + +function renderMessageGroup(view: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean): TemplateResult { + const method: unknown = Reflect.get(view, "renderMessageGroup"); + if (!isRenderMessageGroup(method)) throw new Error("ChatView.renderMessageGroup is not callable"); + return method.call(view, messages, startIndex, endIndex, defaultOpen); +} + +function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] { + const method: unknown = Reflect.get(view, "renderMessageGroupBody"); + if (!isRenderMessageGroupBody(method)) throw new Error("ChatView.renderMessageGroupBody is not callable"); + const calls: GroupBodyRenderCall[] = []; + const observed: RenderMessageGroupBody = function (messages, startIndex) { + calls.push({ messages, startIndex }); + return method.call(this, messages, startIndex); + }; + if (!Reflect.set(view, "renderMessageGroupBody", observed)) throw new Error("Could not observe ChatView.renderMessageGroupBody"); + return calls; +} + +function isRenderMessageGroup(value: unknown): value is RenderMessageGroup { + return typeof value === "function"; +} + +function isRenderMessageGroupBody(value: unknown): value is RenderMessageGroupBody { + return typeof value === "function"; +} + +function templateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler { + const strings = templateStrings(template); + const values = templateValues(template); + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (strings[index]?.includes(marker) === true && isTemplateEventHandler(value)) return value; + } + throw new Error(`Expected template event handler after ${marker}`); +} + +function isTemplateEventHandler(value: unknown): value is TemplateEventHandler { + return typeof value === "function"; +} + +function dispatchDetailsToggle(handler: TemplateEventHandler, open: boolean): void { + const hadDetailsElement = Reflect.has(globalThis, "HTMLDetailsElement"); + const previousDetailsElement = Reflect.get(globalThis, "HTMLDetailsElement"); + class StubDetailsElement extends EventTarget { + constructor(readonly open: boolean) { + super(); + } + } + Reflect.set(globalThis, "HTMLDetailsElement", StubDetailsElement); + try { + const details = new StubDetailsElement(open); + details.addEventListener("toggle", (event) => { handler(event); }); + details.dispatchEvent(new Event("toggle")); + } finally { + if (hadDetailsElement) Reflect.set(globalThis, "HTMLDetailsElement", previousDetailsElement); + else Reflect.deleteProperty(globalThis, "HTMLDetailsElement"); + } +} + +function templateStaticMarkup(template: TemplateResult): string { + const chunks: string[] = []; + visit(template); + return chunks.join(""); + + function visit(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (!isTemplateResult(value)) return; + chunks.push(...templateStrings(value)); + for (const child of templateValues(value)) visit(child); + } +} + +function templateValuesAfterMarker(template: TemplateResult, marker: string): unknown[] { + const matches: unknown[] = []; + visit(template); + return matches; + + function visit(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (!isTemplateResult(value)) return; + const strings = templateStrings(value); + const values = templateValues(value); + for (let index = 0; index < values.length; index += 1) { + if (strings[index]?.includes(marker) === true) matches.push(values[index]); + visit(values[index]); + } + } +} + +function templateStrings(template: TemplateResult): readonly string[] { + const strings = Reflect.get(template, "strings"); + if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); + return strings; +} + +function templateValues(template: TemplateResult): readonly unknown[] { + const values = Reflect.get(template, "values"); + if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable"); + return values.map((value: unknown) => value); +} + +function isTemplateResult(value: unknown): value is TemplateResult { + return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values")); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); +} diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 0e67e7d..8cc549e 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -391,21 +391,27 @@ export class ChatView extends LitElement { ${defaultOpen ? "live events" : "events"} ${summarizeChatGroup(messages)} -
    - ${messages.map((message, offset) => { - const toolOnly = this.isToolExecutionOnlyMessage(message); - return html` -
    - ${toolOnly ? null : this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)} - ${message.parts.map((part) => this.renderPart(part, message))} -
    - `; - })} -
    + ${open ? this.renderMessageGroupBody(messages, startIndex) : null} `; } + private renderMessageGroupBody(messages: ChatLine[], startIndex: number) { + return html` +
    + ${messages.map((message, offset) => { + const toolOnly = this.isToolExecutionOnlyMessage(message); + return html` +
    + ${toolOnly ? null : this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)} + ${message.parts.map((part) => this.renderPart(part, message))} +
    + `; + })} +
    + `; + } + private renderScrollMarker(markerId: string) { return html``; } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 2626f9d..6e8a8d5 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -30,6 +30,7 @@ import { loadExternalPlugins } from "../plugins/external"; import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry"; import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs"; import { AppShellController } from "../appShell/appShellController"; +import { BrowserResumeController } from "../appShell/browserResumeController"; import { NavigationSectionsController, type NavigationSection } from "../appShell/navigationState"; import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController"; import { PanelResizeController, type PanelResizeConstraints, type ResizablePanelSide } from "../appShell/panelResizeController"; @@ -148,6 +149,11 @@ export class PiWebApp extends LitElement { private readonly machineNavigation = new SessionStorageMachineNavigationMemory(); private readonly terminalSelection = new SessionStorageTerminalSelectionMemory(); private readonly appShell = new AppShellController(this); + private readonly browserResume = new BrowserResumeController({ + onResumeSignal: () => { this.handleBrowserResumeSignal(); }, + refreshAfterResume: () => this.refreshAfterBrowserResume(), + onRefreshError: (error) => { console.warn("Failed to refresh after browser resume", error); }, + }); private readonly panelCollapse = new PanelCollapseController(this); private readonly panelResize = new PanelResizeController(this); private readonly navigationSections = new NavigationSectionsController( @@ -191,24 +197,6 @@ export class PiWebApp extends LitElement { this.appShell.repairViewportPosition(); this.retryPendingRemoteRouteRestoreSoon(); }; - private readonly onFocus = () => { - this.appShell.repairViewportPosition(); - void this.sessions.refreshSelectedSession(); - this.schedulePiWebStatusRefresh(); - void this.refreshMachineActivities(); - void this.refreshWorkspaceDeletionRuns(); - this.retryPendingRemoteRouteRestoreSoon(); - }; - private readonly onVisibilityChange = () => { - if (document.visibilityState === "visible") { - this.appShell.repairViewportPosition(); - void this.sessions.refreshSelectedSession(); - this.schedulePiWebStatusRefresh(); - void this.refreshMachineActivities(); - void this.refreshWorkspaceDeletionRuns(); - this.retryPendingRemoteRouteRestoreSoon(); - } - }; private readonly onSystemLightThemeChange = () => { if (this.themePreference.auto) this.applyPreferredTheme(false); }; @@ -232,8 +220,7 @@ export class PiWebApp extends LitElement { super.connectedCallback(); window.addEventListener("popstate", this.onPopState); window.addEventListener("pageshow", this.onPageShow); - window.addEventListener("focus", this.onFocus); - document.addEventListener("visibilitychange", this.onVisibilityChange); + this.browserResume.connect(); window.addEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS); this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange); this.applyPreferredTheme(false); @@ -248,8 +235,7 @@ export class PiWebApp extends LitElement { override disconnectedCallback(): void { window.removeEventListener("popstate", this.onPopState); window.removeEventListener("pageshow", this.onPageShow); - window.removeEventListener("focus", this.onFocus); - document.removeEventListener("visibilitychange", this.onVisibilityChange); + this.browserResume.disconnect(); window.removeEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS); this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange); this.keyboard.reset(); @@ -294,6 +280,20 @@ export class PiWebApp extends LitElement { await this.refreshWorkspaceDeletionRuns(); } + private handleBrowserResumeSignal(): void { + this.appShell.repairViewportPosition(); + this.schedulePiWebStatusRefresh(); + this.retryPendingRemoteRouteRestoreSoon(); + } + + private async refreshAfterBrowserResume(): Promise { + await Promise.all([ + this.sessions.refreshSelectedSession(), + this.refreshMachineActivities(), + this.refreshWorkspaceDeletionRuns(), + ]); + } + private schedulePiWebStatusRefresh(delayMs = PI_WEB_STATUS_DEFER_MS): void { this.clearScheduledPiWebStatusRefresh(); this.piWebStatusDeferredTimer = window.setTimeout(() => { diff --git a/src/client/src/controllers/activityController.test.ts b/src/client/src/controllers/activityController.test.ts index 589b784..a51d248 100644 --- a/src/client/src/controllers/activityController.test.ts +++ b/src/client/src/controllers/activityController.test.ts @@ -12,6 +12,13 @@ function snapshot(...workspaces: WorkspaceActivity[]): WorkspaceActivityResponse return { workspaces, generatedAt: "now" }; } +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolveDeferred: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { resolveDeferred = resolve; }); + if (resolveDeferred === undefined) throw new Error("Deferred promise was not initialized"); + return { promise, resolve: resolveDeferred }; +} + describe("ActivityController", () => { it("stores workspace activity under the requested machine", async () => { let state: AppState = { ...initialAppState(), selectedMachine: { id: "remote", name: "Remote", kind: "remote", createdAt: "now", updatedAt: "now" } }; @@ -29,6 +36,42 @@ describe("ActivityController", () => { }); }); + it("shares duplicate requests and runs one trailing refresh requested during the active fetch", async () => { + const firstSnapshot = deferred(); + const trailingSnapshot = deferred(); + const trailingStarted = deferred(); + let calls = 0; + let state: AppState = { ...initialAppState(), selectedMachine: { id: "local", name: "Local", kind: "local", createdAt: "now", updatedAt: "now" } }; + const controller = new ActivityController(() => state, (patch) => { state = { ...state, ...patch }; }, { + api: { + workspaceActivity: () => { + calls += 1; + if (calls === 2) trailingStarted.resolve(undefined); + return calls === 1 ? firstSnapshot.promise : trailingSnapshot.promise; + }, + }, + }); + + const first = controller.refresh("local"); + const duplicate = controller.refresh("local"); + await Promise.resolve(); + + expect(calls).toBe(1); + + const later = controller.refresh("local"); + const laterDuplicate = controller.refresh("local"); + firstSnapshot.resolve(snapshot(activity("/stale"))); + await trailingStarted.promise; + + expect(calls).toBe(2); + + trailingSnapshot.resolve(snapshot(activity("/fresh"))); + await Promise.all([first, duplicate, later, laterDuplicate]); + + expect(calls).toBe(2); + expect(state.workspaceActivities).toEqual({ "/fresh": activity("/fresh") }); + }); + it("applies live activity updates to the owning machine only", () => { let state: AppState = { ...initialAppState(), selectedMachine: { id: "local", name: "Local", kind: "local", createdAt: "now", updatedAt: "now" } }; const controller = new ActivityController(() => state, (patch) => { state = { ...state, ...patch }; }); diff --git a/src/client/src/controllers/activityController.ts b/src/client/src/controllers/activityController.ts index d50b34e..df6532a 100644 --- a/src/client/src/controllers/activityController.ts +++ b/src/client/src/controllers/activityController.ts @@ -1,6 +1,7 @@ import { activityApi as defaultApi, type WorkspaceActivity, type WorkspaceActivityResponse } from "../api"; import { isWorkspaceActivityActive } from "../../../shared/activity"; import { selectedMachineId, type GetState, type SetState } from "./types"; +import { TrailingRefreshCoordinator } from "./trailingRefreshCoordinator"; export interface ActivityControllerDependencies { api?: Pick; @@ -8,13 +9,16 @@ export interface ActivityControllerDependencies { export class ActivityController { private readonly api: Pick; + private readonly refreshes = new TrailingRefreshCoordinator(); constructor(private readonly getState: GetState, private readonly setState: SetState, deps: ActivityControllerDependencies = {}) { this.api = deps.api ?? defaultApi; } - async refresh(machineId = selectedMachineId(this.getState())): Promise { - this.applyMachineActivitySnapshot(machineId, indexWorkspaceActivities(await this.api.workspaceActivity(machineId))); + refresh(machineId = selectedMachineId(this.getState())): Promise { + return this.refreshes.request(machineId, async () => { + this.applyMachineActivitySnapshot(machineId, indexWorkspaceActivities(await this.api.workspaceActivity(machineId))); + }); } applyWorkspaceActivity(activity: WorkspaceActivity, machineId = selectedMachineId(this.getState())): void { diff --git a/src/client/src/controllers/sessionController.refresh.test.ts b/src/client/src/controllers/sessionController.refresh.test.ts new file mode 100644 index 0000000..46dc640 --- /dev/null +++ b/src/client/src/controllers/sessionController.refresh.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { initialAppState } from "../appState"; +import { SessionController } from "./sessionController"; +import { defaultApi, deferred, FakeSocket, oldSession, replacementSession, sessionLookupId, status, workspace, type AppState, type MessagePage, type SessionStatus } from "./sessionController.testSupport"; + +function page(text: string, total: number): MessagePage { + return { messages: [{ role: "assistant", content: text }], start: 0, total }; +} + +describe("SessionController selected-session refresh", () => { + it("shares same-turn requests and runs one trailing refresh requested during the active fetch", async () => { + const firstPage = deferred(); + const firstStatus = deferred(); + const trailingPage = deferred(); + const trailingStatus = deferred(); + const trailingStarted = deferred(); + let messageCalls = 0; + let statusCalls = 0; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] }; + const api: typeof defaultApi = { + ...defaultApi, + messages: () => { + messageCalls += 1; + if (messageCalls === 2) trailingStarted.resolve(undefined); + return messageCalls === 1 ? firstPage.promise : trailingPage.promise; + }, + status: () => { + statusCalls += 1; + return statusCalls === 1 ? firstStatus.promise : trailingStatus.promise; + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const first = controller.refreshSelectedSession(); + const duplicate = controller.refreshSelectedSession(); + await Promise.resolve(); + + expect(messageCalls).toBe(1); + expect(statusCalls).toBe(1); + + const later = controller.refreshSelectedSession(); + const laterDuplicate = controller.refreshSelectedSession(); + firstPage.resolve(page("stale", 1)); + firstStatus.resolve({ ...status(oldSession.id), messageCount: 1 }); + await trailingStarted.promise; + + expect(messageCalls).toBe(2); + expect(statusCalls).toBe(2); + + trailingPage.resolve(page("fresh", 2)); + trailingStatus.resolve({ ...status(oldSession.id), messageCount: 2 }); + await Promise.all([first, duplicate, later, laterDuplicate]); + + expect(messageCalls).toBe(2); + expect(statusCalls).toBe(2); + expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "fresh" }] }]); + expect(state.status?.messageCount).toBe(2); + }); + + it("does not apply an older refresh after the user selects another session", async () => { + const stalePage = deferred(); + const staleStatus = deferred(); + const replacementPage = page("replacement", 1); + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession, replacementSession] }; + const api: typeof defaultApi = { + ...defaultApi, + messages: (session) => sessionLookupId(session) === oldSession.id ? stalePage.promise : Promise.resolve(replacementPage), + status: (session) => sessionLookupId(session) === oldSession.id ? staleStatus.promise : Promise.resolve(status(replacementSession.id)), + thinkingLevels: () => Promise.resolve({ levels: [] }), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const staleRefresh = controller.refreshSelectedSession(); + await Promise.resolve(); + await controller.selectSession(replacementSession, { updateUrl: false }); + stalePage.resolve(page("old response", 1)); + staleStatus.resolve({ ...status(oldSession.id), messageCount: 1 }); + await staleRefresh; + + expect(state.selectedSession?.id).toBe(replacementSession.id); + expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "replacement" }] }]); + expect(state.status?.sessionId).toBe(replacementSession.id); + }); +}); diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index d9ec288..4249e13 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -14,6 +14,7 @@ import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/ca import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection"; import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types"; +import { TrailingRefreshCoordinator } from "./trailingRefreshCoordinator"; const MESSAGE_PAGE_SIZE = 100; const BULK_FALLBACK_CONCURRENCY = 4; @@ -60,6 +61,12 @@ interface SuppressedCreatedSession { machineId: string; } +interface SelectedSessionRefreshTarget { + session: SessionInfo; + machineId: string; + selectionSeq: number; +} + export class SessionController { private readonly socket: SessionEventSocket; private readonly api: typeof defaultApi; @@ -74,6 +81,7 @@ export class SessionController { private pendingQueuedSendSeq = 0; private readonly pendingSessionStarts = new Map(); private readonly suppressedCreatedSessions = new Map(); + private readonly selectedSessionRefreshes = new TrailingRefreshCoordinator(); constructor( private readonly getState: GetState, @@ -95,6 +103,7 @@ export class SessionController { } dispose() { + this.selectionSeq += 1; this.socket.close(); this.clearPendingUpdates(); } @@ -163,6 +172,7 @@ export class SessionController { isReceivingPartialStream: false, status: session.archived === true ? undefined : this.getState().sessionStatuses[session.id], activity: session.archived === true ? undefined : this.getState().sessionActivities[session.id], + availableThinkingLevels: [], }); try { if (session.archived === true) { @@ -180,11 +190,9 @@ export class SessionController { () => { void this.refreshSelectedSession(session.id); }, selectedMachineId(this.getState()), ); - const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]); - if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return; - const history = this.transcripts.mergeHistory(transcriptKey, page); - this.setState({ ...history, isLoadingEarlierMessages: false, ...this.setStreamCatchup(status.isStreaming ? session.id : undefined), status, activity: this.getState().sessionActivities[session.id], availableThinkingLevels: [] }); - this.applyStatus(status); + const machineId = selectedMachineId(this.getState()); + await this.requestSelectedSessionRefresh({ session, machineId, selectionSeq: seq }); + if (!this.isCurrentRefreshTarget({ session, machineId, selectionSeq: seq })) return; void this.refreshAvailableThinkingLevels(); for (const event of buffered) this.applyEvent(event); this.socket.setHandler((event) => { this.applyEvent(event); }); @@ -725,24 +733,48 @@ export class SessionController { } } - async refreshSelectedSession(sessionId = this.getState().selectedSession?.id): Promise { + refreshSelectedSession(sessionId = this.getState().selectedSession?.id): Promise { const session = this.getState().selectedSession; - if (sessionId === undefined || session?.id !== sessionId || session.archived === true || isClientPendingStartSessionInfo(session)) return; - try { + if (sessionId === undefined || session?.id !== sessionId || session.archived === true || isClientPendingStartSessionInfo(session)) return Promise.resolve(); + const target: SelectedSessionRefreshTarget = { + session, + machineId: selectedMachineId(this.getState()), + selectionSeq: this.selectionSeq, + }; + return this.requestSelectedSessionRefresh(target).catch((error: unknown) => { + if (this.isCurrentRefreshTarget(target)) this.setState({ error: String(error) }); + }); + } + + private requestSelectedSessionRefresh(target: SelectedSessionRefreshTarget): Promise { + const key = machineSessionKey(target.machineId, target.session.id); + return this.selectedSessionRefreshes.request(key, async () => { + if (!this.isCurrentRefreshTarget(target)) return; this.flushPendingUpdates(); - const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]); - if (this.getState().selectedSession?.id !== sessionId) return; - const history = this.transcripts.mergeHistory(this.sessionCacheKey(sessionId), page); + const [page, status] = await Promise.all([ + this.api.messages(target.session, { limit: MESSAGE_PAGE_SIZE }, target.machineId), + this.api.status(target.session, target.machineId), + ]); + if (!this.isCurrentRefreshTarget(target)) return; + const history = this.transcripts.mergeHistory(key, page); this.setState({ ...history, status, - activity: this.getState().sessionActivities[sessionId], - ...this.setStreamCatchup(status.isStreaming ? sessionId : undefined), + activity: this.getState().sessionActivities[target.session.id], + ...this.setStreamCatchup(status.isStreaming ? target.session.id : undefined), }); this.applyStatus(status); - } catch (error) { - if (this.getState().selectedSession?.id === sessionId) this.setState({ error: String(error) }); - } + }); + } + + private isCurrentRefreshTarget(target: SelectedSessionRefreshTarget): boolean { + const state = this.getState(); + const selected = state.selectedSession; + return target.selectionSeq === this.selectionSeq + && selectedMachineId(state) === target.machineId + && selected?.id === target.session.id + && selected.archived !== true + && !isClientPendingStartSessionInfo(selected); } private applyBulkSessionFailures(action: string, failures: readonly string[]): void { diff --git a/src/client/src/controllers/trailingRefreshCoordinator.ts b/src/client/src/controllers/trailingRefreshCoordinator.ts new file mode 100644 index 0000000..555f889 --- /dev/null +++ b/src/client/src/controllers/trailingRefreshCoordinator.ts @@ -0,0 +1,57 @@ +interface PendingRefresh { + promise: Promise; + latestRefresh: () => Promise; + started: boolean; + trailing: boolean; +} + +/** + * Shares refresh work requested in the same task and collapses requests made + * during an active refresh into one trailing pass, without losing later passes. + */ +export class TrailingRefreshCoordinator { + private readonly pendingByKey = new Map(); + + request(key: Key, refresh: () => Promise): Promise { + const existing = this.pendingByKey.get(key); + if (existing !== undefined) { + existing.latestRefresh = refresh; + if (existing.started) existing.trailing = true; + return existing.promise; + } + + const pending: PendingRefresh = { + promise: Promise.resolve(), + latestRefresh: refresh, + started: false, + trailing: false, + }; + pending.promise = Promise.resolve() + .then(async () => { + let latestError: unknown; + let latestFailed: boolean; + do { + pending.trailing = false; + const runRefresh = pending.latestRefresh; + pending.started = true; + latestFailed = false; + try { + await runRefresh(); + } catch (error) { + latestError = error; + latestFailed = true; + } + } while (this.hasTrailingRequest(pending)); + if (latestFailed) throw latestError; + }) + .finally(() => { + if (this.pendingByKey.get(key) === pending) this.pendingByKey.delete(key); + }); + this.pendingByKey.set(key, pending); + return pending.promise; + } + + private hasTrailingRequest(pending: PendingRefresh): boolean { + return pending.trailing; + } +} diff --git a/src/server/app.compression.test.ts b/src/server/app.compression.test.ts new file mode 100644 index 0000000..f87cb4e --- /dev/null +++ b/src/server/app.compression.test.ts @@ -0,0 +1,90 @@ +import { Readable } from "node:stream"; +import { gunzipSync } from "node:zlib"; +import { describe, expect, it, vi } from "vitest"; +import { appTestContext, fakeRemoteClient, registerAppTestHooks } from "./app.testSupport.js"; + +registerAppTestHooks(); + +describe("browser-facing HTTP compression", () => { + it("negotiates compression for large local-machine API responses", async () => { + const marker = "local transcript content ".repeat(256); + appTestContext.piWebConfig = { + plugins: { fake: { settings: { marker } } }, + }; + + const compressed = await appTestContext.app.inject({ + method: "GET", + url: "/api/machines/local/config", + headers: { "accept-encoding": "gzip" }, + }); + const identity = await appTestContext.app.inject({ + method: "GET", + url: "/api/machines/local/config", + headers: { "accept-encoding": "identity" }, + }); + + expect(compressed.statusCode).toBe(200); + expect(compressed.headers["content-encoding"]).toBe("gzip"); + expect(compressed.headers["content-length"]).toBeUndefined(); + expect(compressed.headers.vary).toContain("accept-encoding"); + expect(gunzipJson(compressed)).toMatchObject({ effectiveConfig: { plugins: { fake: { settings: { marker } } } } }); + + expect(identity.statusCode).toBe(200); + expect(identity.headers["content-encoding"]).toBeUndefined(); + expect(identity.json()).toMatchObject({ effectiveConfig: { plugins: { fake: { settings: { marker } } } } }); + }); + + it("negotiates compression after streaming a remote-machine API response", async () => { + const addResponse = await appTestContext.app.inject({ + method: "POST", + url: "/api/machines", + payload: { name: "Remote", baseUrl: "https://remote.example.test/" }, + }); + const remote = addResponse.json<{ id: string }>(); + const projects = Array.from({ length: 64 }, (_, index) => ({ + id: `p-${String(index)}`, + name: `Remote project ${String(index)}`, + path: `/repos/project-${String(index)}`, + createdAt: "2026-07-11T00:00:00.000Z", + })); + const body = JSON.stringify(projects); + const request = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { + "content-type": "application/json", + "content-length": String(Buffer.byteLength(body)), + }, + body: Readable.from([body]), + })); + appTestContext.remoteClient = fakeRemoteClient({ request }); + const url = `/api/machines/${remote.id}/projects`; + + const compressed = await appTestContext.app.inject({ + method: "GET", + url, + headers: { "accept-encoding": "gzip" }, + }); + const identity = await appTestContext.app.inject({ + method: "GET", + url, + headers: { "accept-encoding": "identity" }, + }); + + expect(compressed.statusCode).toBe(200); + expect(compressed.headers["content-encoding"]).toBe("gzip"); + expect(compressed.headers["content-length"]).toBeUndefined(); + expect(compressed.headers.vary).toContain("accept-encoding"); + expect(gunzipJson(compressed)).toEqual(projects); + + expect(identity.statusCode).toBe(200); + expect(identity.headers["content-encoding"]).toBeUndefined(); + expect(identity.json()).toEqual(projects); + expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/projects", undefined); + expect(request).toHaveBeenNthCalledWith(2, "GET", "/api/projects", undefined); + }); +}); + +function gunzipJson(response: { rawPayload: Buffer }): unknown { + const value: unknown = JSON.parse(gunzipSync(response.rawPayload).toString("utf8")); + return value; +} diff --git a/src/server/app.ts b/src/server/app.ts index aeb4913..8d28f45 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify"; +import fastifyCompress from "@fastify/compress"; import fastifyStatic from "@fastify/static"; import fastifyWebsocket from "@fastify/websocket"; import { ProjectStore } from "./storage/projectStore.js"; @@ -120,6 +121,13 @@ function registerLocalFileSuggestionRoutes(app: FastifyInstance, projects: Proje export async function buildApp(deps: AppDependencies = {}): Promise { const app = Fastify({ logger: deps.logger ?? true, ...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }) }); + // Vite proxies development API requests here, while production and machine-scoped + // API requests already terminate here, so this is the shared browser HTTP edge. + await app.register(fastifyCompress, { + globalCompression: true, + globalDecompression: false, + threshold: 1024, + }); await app.register(fastifyWebsocket); const projects = deps.projects ?? new ProjectService(new ProjectStore()); diff --git a/src/server/browserMessageProjection.test.ts b/src/server/browserMessageProjection.test.ts new file mode 100644 index 0000000..d41d546 --- /dev/null +++ b/src/server/browserMessageProjection.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { normalizeMessage } from "../client/src/chatMessages.js"; +import type { MessagePage } from "../shared/apiTypes.js"; +import { projectBrowserMessage, projectBrowserMessageResponse, projectBrowserSessionEvent } from "./browserMessageProjection.js"; + +function signedAssistantMessage() { + return { + role: "assistant", + content: [ + { type: "thinking", thinking: "private chain", thinkingSignature: "opaque-provider-payload", redacted: true }, + { type: "text", text: "visible answer", textSignature: "text-metadata" }, + { type: "toolCall", name: "read", arguments: { thinkingSignature: "ordinary nested argument" }, thoughtSignature: "tool-metadata" }, + ], + model: "model-1", + }; +} + +describe("browser message projection", () => { + it("omits only thinking-block signatures without mutating runtime messages", () => { + const message = signedAssistantMessage(); + + const projected = projectBrowserMessage(message); + + expect(projected).toEqual({ + role: "assistant", + content: [ + { type: "thinking", thinking: "private chain", redacted: true }, + { type: "text", text: "visible answer", textSignature: "text-metadata" }, + { type: "toolCall", name: "read", arguments: { thinkingSignature: "ordinary nested argument" }, thoughtSignature: "tool-metadata" }, + ], + model: "model-1", + }); + expect(message.content[0]).toEqual({ type: "thinking", thinking: "private chain", thinkingSignature: "opaque-provider-payload", redacted: true }); + expect(normalizeMessage(projected)).toEqual(normalizeMessage(message)); + }); + + it("projects both paged and legacy array history responses", () => { + const message = signedAssistantMessage(); + const page: MessagePage = { messages: [message], start: 4, total: 5 }; + + expect(projectBrowserMessageResponse(page)).toEqual({ + messages: [{ ...message, content: [{ type: "thinking", thinking: "private chain", redacted: true }, ...message.content.slice(1)] }], + start: 4, + total: 5, + }); + expect(projectBrowserMessageResponse([message])).toEqual([ + { ...message, content: [{ type: "thinking", thinking: "private chain", redacted: true }, ...message.content.slice(1)] }, + ]); + expect(page.messages[0]).toBe(message); + }); + + it("projects final-message events but leaves other event shapes untouched", () => { + const message = signedAssistantMessage(); + const finalEvent = { type: "message.end" as const, message }; + const appendEvent = { type: "message.append" as const, message }; + + expect(projectBrowserSessionEvent(finalEvent)).toEqual({ + type: "message.end", + message: { ...message, content: [{ type: "thinking", thinking: "private chain", redacted: true }, ...message.content.slice(1)] }, + }); + expect(projectBrowserSessionEvent(appendEvent)).toBe(appendEvent); + expect(finalEvent.message).toBe(message); + }); +}); diff --git a/src/server/browserMessageProjection.ts b/src/server/browserMessageProjection.ts new file mode 100644 index 0000000..54f110c --- /dev/null +++ b/src/server/browserMessageProjection.ts @@ -0,0 +1,59 @@ +import type { MessagePage, SessionUiEvent } from "../shared/apiTypes.js"; + +/** + * Remove provider-only thinking data at the browser transport boundary. The + * runtime message remains unchanged because only affected messages and content + * blocks are copied. + */ +export function projectBrowserMessage(message: unknown): unknown { + if (!isRecord(message)) return message; + const originalContent = message["content"]; + if (!isUnknownArray(originalContent)) return message; + + const content = mapChanged(originalContent, (part) => { + if (!isRecord(part) || part["type"] !== "thinking" || !Object.hasOwn(part, "thinkingSignature")) return part; + const projected = { ...part }; + delete projected["thinkingSignature"]; + return projected; + }); + + return content === originalContent ? message : { ...message, content }; +} + +export function projectBrowserMessageResponse(response: unknown[] | MessagePage): unknown[] | MessagePage { + if (Array.isArray(response)) return mapChanged(response, projectBrowserMessage); + const messages = mapChanged(response.messages, projectBrowserMessage); + return messages === response.messages ? response : { ...response, messages }; +} + +export function projectBrowserSessionEvent(event: SessionUiEvent): SessionUiEvent { + if (event.type !== "message.end" || event.message === undefined) return event; + const message = projectBrowserMessage(event.message); + return message === event.message ? event : { ...event, message }; +} + +function mapChanged(values: T[], project: (value: T) => T): T[] { + let projectedValues: T[] | undefined; + let index = 0; + for (const value of values) { + const projected = project(value); + if (projectedValues === undefined) { + if (projected === value) { + index += 1; + continue; + } + projectedValues = values.slice(0, index); + } + projectedValues.push(projected); + index += 1; + } + return projectedValues ?? values; +} + +function isUnknownArray(value: unknown): value is unknown[] { + return Array.isArray(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/server/machines/machineClient.test.ts b/src/server/machines/machineClient.test.ts index ed15e92..adcf79a 100644 --- a/src/server/machines/machineClient.test.ts +++ b/src/server/machines/machineClient.test.ts @@ -29,6 +29,38 @@ describe("RemoteMachineClient", () => { expect(new Headers(init.headers).get("content-type")).toBe("application/json"); expect(init.body).toBe(JSON.stringify({ cwd: "/repo" })); }); + + it("requests compression for the remote hop even when configured headers use different casing", async () => { + const fetchImpl = vi.fn(() => Promise.resolve(new Response("ok", { status: 200 }))); + const client = new RemoteMachineClient({ + baseUrl: "https://remote.example.test/", + headers: { "Accept-Encoding": "identity" }, + }, fetchImpl); + + await client.request("GET", "/api/projects"); + + const { init } = onlyFetchCall(fetchImpl); + expect(new Headers(init.headers).get("accept-encoding")).toBe("gzip, deflate"); + }); + + it("removes stale representation headers after Fetch decodes a compressed response", async () => { + const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { + "content-type": "application/json", + "content-encoding": "gzip", + "content-length": "31", + }, + }))); + const client = new RemoteMachineClient({ baseUrl: "https://remote.example.test/" }, fetchImpl); + + const response = await client.requestJson("GET", "/api/projects"); + + expect(response.body).toEqual({ ok: true }); + expect(response.headers["content-type"]).toBe("application/json"); + expect(response.headers["content-encoding"]).toBeUndefined(); + expect(response.headers["content-length"]).toBeUndefined(); + }); }); function fetchInputUrl(input: RequestInfo | URL): string { diff --git a/src/server/machines/machineClient.ts b/src/server/machines/machineClient.ts index 57b2b96..cc743e6 100644 --- a/src/server/machines/machineClient.ts +++ b/src/server/machines/machineClient.ts @@ -28,6 +28,8 @@ export interface MachineClient { export const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 30_000; export const DEFAULT_REMOTE_HEALTH_TIMEOUT_MS = 3_000; +const REMOTE_RESPONSE_ACCEPT_ENCODING = "gzip, deflate"; + const BLOCKED_CONFIGURED_HEADER_NAMES = new Set([ "host", "connection", @@ -57,7 +59,7 @@ export class RemoteMachineClient implements MachineClient { const response = await this.fetchResponse(method, path, body, options); return { statusCode: response.status, - headers: headersToRecord(response.headers), + headers: decodedResponseHeaders(response.headers), ...(response.body === null ? {} : { body: readableFromWebResponseBody(response.body) }), }; } @@ -68,7 +70,7 @@ export class RemoteMachineClient implements MachineClient { const parsed: unknown = text === "" ? undefined : JSON.parse(text); return { statusCode: response.status, - headers: headersToRecord(response.headers), + headers: decodedResponseHeaders(response.headers), body: parsed, }; } @@ -100,12 +102,12 @@ export class RemoteMachineClient implements MachineClient { } } - private requestHeaders(body: unknown, options: MachineRequestOptions): HeadersInit { - return { - ...this.remoteHeaders(), - accept: "*/*", - ...(body === undefined ? {} : { "content-type": options.contentType ?? defaultContentTypeForBody(body) }), - }; + private requestHeaders(body: unknown, options: MachineRequestOptions): Headers { + const headers = new Headers(this.remoteHeaders()); + headers.set("accept", "*/*"); + headers.set("accept-encoding", REMOTE_RESPONSE_ACCEPT_ENCODING); + if (body !== undefined) headers.set("content-type", options.contentType ?? defaultContentTypeForBody(body)); + return headers; } private remoteHeaders(): Record { @@ -145,8 +147,16 @@ function filterConfiguredHeaders(headers: Record | undefined): R return Object.fromEntries(Object.entries(headers).filter(([key]) => !BLOCKED_CONFIGURED_HEADER_NAMES.has(key.toLowerCase()))); } -function headersToRecord(headers: Headers): Record { - return Object.fromEntries(headers.entries()); +function decodedResponseHeaders(headers: Headers): Record { + const values: Record = Object.fromEntries(headers.entries()); + const contentEncoding = values["content-encoding"]; + if (contentEncoding !== undefined && contentEncoding !== "identity") { + // Fetch decodes response bodies but retains headers for the encoded wire + // representation. The outer HTTP edge must negotiate and frame the decoded body. + delete values["content-encoding"]; + delete values["content-length"]; + } + return values; } function serializeRequestBody(method: string, body: unknown): NonNullable | undefined { diff --git a/src/server/realtime/sessionEventHub.test.ts b/src/server/realtime/sessionEventHub.test.ts index a3e3182..6397759 100644 --- a/src/server/realtime/sessionEventHub.test.ts +++ b/src/server/realtime/sessionEventHub.test.ts @@ -22,6 +22,22 @@ describe("SessionEventHub", () => { expect(otherSocket.send).not.toHaveBeenCalled(); }); + it("omits thinking signatures from final-message payloads without mutating source events", () => { + const hub = new SessionEventHub(); + const socket = new FakeSocket(); + hub.add("s1", socket); + const thinkingBlock = { type: "thinking", thinking: "private chain", thinkingSignature: "opaque-provider-payload", redacted: true }; + const message = { role: "assistant", content: [thinkingBlock, { type: "text", text: "visible answer" }] }; + + hub.publish("s1", { type: "message.end", message }); + + expect(socket.send).toHaveBeenCalledWith(JSON.stringify({ + type: "message.end", + message: { role: "assistant", content: [{ type: "thinking", thinking: "private chain", redacted: true }, { type: "text", text: "visible answer" }] }, + })); + expect(thinkingBlock.thinkingSignature).toBe("opaque-provider-payload"); + }); + it("removes session sockets on close and skips non-open sockets", () => { const hub = new SessionEventHub(); const closed = new FakeSocket(); diff --git a/src/server/realtime/sessionEventHub.ts b/src/server/realtime/sessionEventHub.ts index 77ca5df..d38ed42 100644 --- a/src/server/realtime/sessionEventHub.ts +++ b/src/server/realtime/sessionEventHub.ts @@ -1,4 +1,5 @@ import type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes.js"; +import { projectBrowserSessionEvent } from "../browserMessageProjection.js"; export interface RealtimeSocket { readonly OPEN: number; @@ -29,7 +30,7 @@ export class SessionEventHub { } publish(sessionId: string, event: SessionUiEvent): void { - const payload = JSON.stringify(event); + const payload = JSON.stringify(projectBrowserSessionEvent(event)); for (const socket of this.socketsBySession.get(sessionId) ?? []) { if (socket.readyState === socket.OPEN) socket.send(payload); } diff --git a/src/server/sessions/piSessionService.lifecycle.test.ts b/src/server/sessions/piSessionService.lifecycle.test.ts index 12afa09..67f5e6a 100644 --- a/src/server/sessions/piSessionService.lifecycle.test.ts +++ b/src/server/sessions/piSessionService.lifecycle.test.ts @@ -2,8 +2,18 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; -import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js"; +import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js"; +import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} describe("PiSessionService lifecycle, listing, and reload", () => { it("starts sessions through an injected runtime creator", async () => { @@ -85,6 +95,156 @@ describe("PiSessionService lifecycle, listing, and reload", () => { await service.dispose(); }); + it("shares one runtime when concurrent cold lookups resolve to the same session", async () => { + const sessionId = "single-flight-session"; + const createStarted = deferred(); + const releaseCreate = deferred(); + const winnerUnsubscribe = vi.fn(); + const loserUnsubscribe = vi.fn(); + const winnerSubscribe = vi.fn(() => winnerUnsubscribe); + const loserSubscribe = vi.fn(() => loserUnsubscribe); + const winner = fakeRuntime(sessionId, { + sessionManager: fakeSessionManager("/workspace", { + getSessionId: () => sessionId, + getBranch: () => [{ type: "message", message: { role: "user", content: "shared runtime" } }], + }), + subscribe: winnerSubscribe, + }); + const loser = fakeRuntime(sessionId, { + sessionManager: fakeSessionManager("/workspace", { getSessionId: () => sessionId }), + subscribe: loserSubscribe, + }); + const runtimes = [winner.runtime, loser.runtime]; + let createCalls = 0; + const createAgentRuntime: RuntimeCreator = async () => { + const runtime = runtimes[createCalls]; + createCalls += 1; + createStarted.resolve(); + await releaseCreate.promise; + if (runtime === undefined) throw new Error("unexpected runtime creation"); + return runtime; + }; + const gateway = sessionGateway([sessionRecord(sessionId)]); + const open = vi.spyOn(gateway, "open"); + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: emptyArchiveStore(), + createAgentRuntime, + sessionManager: gateway, + heartbeatIntervalMs: 60_000, + }); + + const messagesPromise = service.messages(sessionRef(sessionId)); + await createStarted.promise; + const statusPromise = service.status(sessionRef("single-flight")); + await new Promise((resolve) => setImmediate(resolve)); + const callsWhileOpening = createCalls; + releaseCreate.resolve(); + + const [messages, status] = await Promise.all([messagesPromise, statusPromise]); + const activeCount = service.activeCount(); + await service.dispose(); + + expect(callsWhileOpening).toBe(1); + expect(createCalls).toBe(1); + expect(open).toHaveBeenCalledOnce(); + expect(activeCount).toBe(1); + expect(messages).toEqual([{ role: "user", content: "shared runtime" }]); + expect(status).toMatchObject({ sessionId }); + expect(winnerSubscribe).toHaveBeenCalledOnce(); + expect(winnerUnsubscribe).toHaveBeenCalledOnce(); + expect(winner.calls.dispose).toBe(1); + expect(loserSubscribe).not.toHaveBeenCalled(); + expect(loserUnsubscribe).not.toHaveBeenCalled(); + expect(loser.calls.dispose).toBe(0); + }); + + it("clears a failed pending open so the session can be retried", async () => { + const sessionId = "retry-open-session"; + const bindStarted = deferred(); + const bindResult = deferred(); + const openingError = new Error("extension binding failed"); + const failed = fakeRuntime(sessionId, { + bindExtensions: () => { + bindStarted.resolve(); + return bindResult.promise; + }, + }); + const retried = fakeRuntime(sessionId); + const runtimes = [failed.runtime, retried.runtime]; + let createCalls = 0; + const createAgentRuntime: RuntimeCreator = () => { + const runtime = runtimes[createCalls]; + createCalls += 1; + return runtime === undefined + ? Promise.reject(new Error("unexpected runtime creation")) + : Promise.resolve(runtime); + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: emptyArchiveStore(), + createAgentRuntime, + sessionManager: sessionGateway([sessionRecord(sessionId)]), + heartbeatIntervalMs: 60_000, + }); + + const messagesPromise = service.messages(sessionRef(sessionId)); + await bindStarted.promise; + const statusPromise = service.status(sessionRef("retry-open")); + await new Promise((resolve) => setImmediate(resolve)); + const callsWhileOpening = createCalls; + const failedLookups = Promise.allSettled([messagesPromise, statusPromise]); + bindResult.reject(openingError); + + const outcomes = await failedLookups; + expect(callsWhileOpening).toBe(1); + expect(outcomes).toHaveLength(2); + for (const outcome of outcomes) { + expect(outcome.status).toBe("rejected"); + if (outcome.status === "rejected") expect(outcome.reason).toBe(openingError); + } + expect(service.activeCount()).toBe(0); + expect(failed.calls.abort).toBe(1); + expect(failed.calls.dispose).toBe(1); + + await expect(service.status(sessionRef(sessionId))).resolves.toMatchObject({ sessionId }); + expect(createCalls).toBe(2); + expect(service.activeCount()).toBe(1); + + await service.dispose(); + expect(retried.calls.dispose).toBe(1); + }); + + it("waits for an in-flight open before disposing the service", async () => { + const sessionId = "dispose-opening-session"; + const createStarted = deferred(); + const runtimeResult = deferred(); + const fake = fakeRuntime(sessionId); + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: emptyArchiveStore(), + createAgentRuntime: () => { + createStarted.resolve(); + return runtimeResult.promise; + }, + sessionManager: sessionGateway([sessionRecord(sessionId)]), + heartbeatIntervalMs: 60_000, + }); + + const statusPromise = service.status(sessionRef(sessionId)); + await createStarted.promise; + let disposeSettled = false; + const disposePromise = service.dispose().then(() => { disposeSettled = true; }); + await new Promise((resolve) => setImmediate(resolve)); + const settledWhileOpening = disposeSettled; + runtimeResult.resolve(fake.runtime); + + await expect(statusPromise).resolves.toMatchObject({ sessionId }); + await disposePromise; + + expect(settledWhileOpening).toBe(false); + expect(service.activeCount()).toBe(0); + expect(fake.calls.abort).toBe(1); + expect(fake.calls.dispose).toBe(1); + }); + it("binds extensions again when the SDK runtime replaces the active session", async () => { const hub = new CapturingSessionEventHub(); const fake = fakeRuntime("session-1"); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 3affafe..69712aa 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -31,7 +31,7 @@ import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachm import { parsePromptAttachments } from "../../shared/promptAttachments.js"; import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef } from "../../shared/apiTypes.js"; -import { cwdPathsEqual } from "../workingDirectory.js"; +import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js"; import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js"; import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js"; import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js"; @@ -261,6 +261,11 @@ export interface PiSessionRuntime { dispose(): Promise; } +interface PendingSessionOpen { + sessionId: string; + promise: Promise>; +} + interface CreateAgentRuntimeOptions { cwd: string; agentDir: string; @@ -404,6 +409,7 @@ export interface PiSessionServiceDependencies { export class PiSessionService { private readonly active = new Map>(); + private readonly pendingSessionOpens = new Map(); private readonly activities = new Map(); private readonly heartbeat: NodeJS.Timeout; private readonly commandService: SessionCommandService; @@ -533,8 +539,11 @@ export class PiSessionService { async dispose(): Promise { clearInterval(this.heartbeat); this.clearCompactionDrainTimers(); + const pendingOpens = this.pendingSessionOpenPromises(); + if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens); const activeSessions = Array.from(new Set(this.active.values())); this.active.clear(); + this.pendingSessionOpens.clear(); this.activities.clear(); this.compactionPromptQueues.clear(); this.authLossWarnings.clear(); @@ -546,8 +555,11 @@ export class PiSessionService { await Promise.all(activeSessions.map(async (active) => { active.unsubscribe(); this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd()); - await active.runtime.session.abort(); - await active.runtime.dispose(); + try { + await active.runtime.session.abort(); + } finally { + await active.runtime.dispose(); + } })); } @@ -1540,6 +1552,8 @@ export class PiSessionService { } private async closeActive(sessionId: string): Promise { + const pendingOpens = this.pendingSessionOpenPromises(sessionId); + if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens); const active = this.active.get(sessionId); if (!active) return; this.active.delete(sessionId); @@ -1573,13 +1587,49 @@ export class PiSessionService { if (active !== undefined) return active; const archived = await this.getArchived(ref); - if (archived?.archivePath !== undefined) return this.create(this.sessionManager.open(archived.archivePath), archived.cwd); + if (archived?.archivePath !== undefined) { + const { archivePath } = archived; + return this.openExistingSession( + archived.sessionId, + archived.cwd, + () => this.sessionManager.open(archivePath), + ); + } const match = isPiSessionRef(ref) ? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id)) : (await this.sessionManager.listAll?.() ?? []).find((s) => s.id === ref || s.id.startsWith(ref)); if (!match) throw new Error("Session not found"); - return this.create(this.sessionManager.open(match.path), match.cwd); + return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path)); + } + + private openExistingSession( + sessionId: string, + cwd: string, + openSessionManager: () => PiSessionManager, + ): Promise> { + const active = this.activeForLookup({ id: sessionId, cwd }); + if (active !== undefined) return Promise.resolve(active); + + const key = JSON.stringify([canonicalizeStoredCwd(cwd), sessionId]); + const existing = this.pendingSessionOpens.get(key); + if (existing !== undefined) return existing.promise; + + const pending: PendingSessionOpen = { + sessionId, + promise: this.create(openSessionManager(), cwd), + }; + pending.promise = pending.promise.finally(() => { + if (this.pendingSessionOpens.get(key) === pending) this.pendingSessionOpens.delete(key); + }); + this.pendingSessionOpens.set(key, pending); + return pending.promise; + } + + private pendingSessionOpenPromises(sessionId?: string): Promise>[] { + return [...this.pendingSessionOpens.values()] + .filter((pending) => sessionId === undefined || pending.sessionId === sessionId) + .map((pending) => pending.promise); } private async getArchived(ref: PiSessionLookup): Promise { @@ -1613,18 +1663,40 @@ export class PiSessionService { delegationToolsEnabled, ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }), }); - await this.bindSessionExtensions(runtime.session); const active: ActiveSession = { runtime, unsubscribe: noop }; - this.bindRuntime(active); - runtime.setRebindSession(async (session) => { - await this.bindSessionExtensions(session); + try { + await this.bindSessionExtensions(runtime.session); this.bindRuntime(active); - await this.recoverSubsessionTrackingForOpenedSession(session); - }); - this.active.set(runtime.session.sessionId, active); - await this.recoverSubsessionTrackingForOpenedSession(runtime.session); - this.publishStatus(runtime.session); - return active; + runtime.setRebindSession(async (session) => { + await this.bindSessionExtensions(session); + this.bindRuntime(active); + await this.recoverSubsessionTrackingForOpenedSession(session); + }); + this.active.set(runtime.session.sessionId, active); + await this.recoverSubsessionTrackingForOpenedSession(runtime.session); + this.publishStatus(runtime.session); + return active; + } catch (error: unknown) { + active.unsubscribe(); + let removedActive = false; + for (const [sessionId, candidate] of this.active.entries()) { + if (candidate !== active) continue; + this.active.delete(sessionId); + this.activities.delete(sessionId); + this.clearAuthLossWarningsForSession(sessionId); + this.clearCompactionPromptQueue(sessionId); + removedActive = true; + } + if (removedActive) { + this.workspaceActivity?.removeSession(runtime.session.sessionId, runtime.session.sessionManager.getCwd()); + } + try { + await runtime.session.abort(); + } finally { + await runtime.dispose(); + } + throw error; + } } private async bindSessionExtensions(session: PiAgentSession): Promise { diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts index abef639..8142442 100644 --- a/src/server/sessions/sessionRoutes.test.ts +++ b/src/server/sessions/sessionRoutes.test.ts @@ -2,7 +2,7 @@ import { resolve } from "node:path"; import Fastify, { type FastifyInstance } from "fastify"; import fastifyWebsocket from "@fastify/websocket"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js"; +import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js"; import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js"; import { registerSessionRoutes } from "./sessionRoutes.js"; @@ -55,6 +55,32 @@ describe("session routes", () => { } }); + it("omits thinking signatures from browser history without mutating service messages", async () => { + const routeApp = Fastify({ logger: false }); + await routeApp.register(fastifyWebsocket); + const eventHub = new SessionEventHub(); + const routeService = new CapturingRouteSessionService(eventHub); + const thinkingBlock = { type: "thinking", thinking: "private chain", thinkingSignature: "opaque-provider-payload", redacted: true }; + const message = { role: "assistant", content: [thinkingBlock, { type: "text", text: "visible answer" }] }; + routeService.messagesResponse = { messages: [message], start: 0, total: 1 }; + registerSessionRoutes(routeApp, routeService, eventHub); + + try { + const response = await routeApp.inject({ method: "GET", url: "/sessions/session-1/messages?limit=20" }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "private chain", redacted: true }, { type: "text", text: "visible answer" }] }], + start: 0, + total: 1, + }); + expect(thinkingBlock.thinkingSignature).toBe("opaque-provider-payload"); + } finally { + await routeService.dispose(); + await routeApp.close(); + } + }); + it("forwards prompt attachments and supports the save-attachments route", async () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); @@ -226,6 +252,7 @@ describe("session routes", () => { class CapturingRouteSessionService extends PiSessionService { readonly calls: unknown[] = []; readonly reloadCalls: (string | PiSessionRef)[] = []; + messagesResponse: unknown[] | MessagePage = []; readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = []; readonly cleanupCalls: NormalizedSessionCleanupRequest[] = []; readonly bulkArchiveCalls: SessionBulkMutationRef[][] = []; @@ -262,6 +289,10 @@ class CapturingRouteSessionService extends PiSessionService { return Promise.resolve(); } + override messages(): Promise { + return Promise.resolve(this.messagesResponse); + } + override status(lookup: string | PiSessionRef) { this.calls.push(lookup); return Promise.resolve({ diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 3aeef8a..cc57600 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from "fastify"; import type { SessionBulkMutationRequest, SessionBulkMutationRef, SessionCleanupRequest } from "../../shared/apiTypes.js"; +import { projectBrowserMessageResponse } from "../browserMessageProjection.js"; import { normalizeRequestCwd } from "../workingDirectory.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import type { PiSessionRef, PiSessionService } from "./piSessionService.js"; @@ -83,7 +84,8 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS app.get<{ Params: { sessionId: string }; Querystring: MessageQuery }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => { try { const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) }; - return await sessions.messages(sessionLookupFromQuery(request.params.sessionId, request.query), page); + const messages = await sessions.messages(sessionLookupFromQuery(request.params.sessionId, request.query), page); + return projectBrowserMessageResponse(messages); } catch (error) { return reply.code(404).send({ error: errorMessage(error) }); } From 205bda3480fb1164ec584b2fbd55e90b966078d3 Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Sat, 11 Jul 2026 19:27:30 +0000 Subject: [PATCH 085/111] perf: scope pre-commit validation --- .githooks/pre-commit | 4 +- package.json | 2 + scripts/verify-staged.mjs | 202 +++++++++++++++++++++++++++++++++ scripts/verify-staged.test.mjs | 106 +++++++++++++++++ vitest.config.ts | 2 +- 5 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 scripts/verify-staged.mjs create mode 100644 scripts/verify-staged.test.mjs diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 8cc1b15..39d4981 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,5 +1,5 @@ #!/usr/bin/env sh set -eu -echo "Running pre-commit checks: npm run verify" -npm run verify +echo "Running pre-commit checks: npm run verify:staged" +npm run verify:staged diff --git a/package.json b/package.json index 115719b..01370a4 100644 --- a/package.json +++ b/package.json @@ -35,10 +35,12 @@ "build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs", "capture:screenshots": "node scripts/capture-screenshots.mjs", "typecheck": "tsc --noEmit", + "typecheck:cached": "tsc --noEmit --incremental --tsBuildInfoFile node_modules/.cache/pi-web/typecheck.tsbuildinfo", "knip": "knip", "lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" vite.config.ts vitest.config.ts", "test": "vitest run --config vitest.config.ts", "verify": "npm run typecheck && npm run lint && npm run knip && npm test", + "verify:staged": "node scripts/verify-staged.mjs", "start": "tsx src/server/index.ts", "start:sessiond": "tsx src/server/sessiond.ts", "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", diff --git a/scripts/verify-staged.mjs b/scripts/verify-staged.mjs new file mode 100644 index 0000000..37e4c06 --- /dev/null +++ b/scripts/verify-staged.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const FULL_LINT_TRIGGERS = new Set([ + "eslint.config.js", + "tsconfig.json", +]); + +const FULL_TEST_TRIGGERS = new Set([ + "tsconfig.json", + "vitest.config.ts", +]); + +const LINTABLE_ROOT_FILES = new Set([ + "vite.config.ts", + "vitest.config.ts", +]); + +const LINTABLE_DIRECTORIES = [ + "extensions/", + "pi-web-plugins/", + "src/", +]; + +const RELATED_SOURCE_DIRECTORIES = [ + "extensions/", + "pi-web-plugins/", + "plugin-api/", + "scripts/", + "src/", +]; + +// `vitest related` follows imports, but these suites inspect repository assets at runtime. +const DOCKER_TESTS = [ + "src/docker/piWebDockerDocs.test.ts", + "src/docker/piWebDockerEntrypoint.test.ts", + "src/server/dockerControlAssets.test.ts", +]; + +const DOCKER_DOCS_TEST = "src/docker/piWebDockerDocs.test.ts"; +const PLUGIN_PUBLIC_API_TEST = "pi-web-plugins/pluginPublicApi.test.ts"; + +export function parseNullDelimitedPaths(output) { + const value = Buffer.isBuffer(output) ? output.toString("utf8") : output; + return value.split("\0").filter((path) => path.length > 0); +} + +export function createValidationPlan(stagedPaths, options = {}) { + const pathExists = options.pathExists ?? existsSync; + const paths = [...new Set(stagedPaths.map(normalizeRepoPath).filter((path) => path.length > 0))].sort(); + + const lint = paths.some((path) => FULL_LINT_TRIGGERS.has(path)) + ? { mode: "full", files: [] } + : scopedValidation(paths.filter((path) => isLintablePath(path) && pathExists(path)), "scoped"); + + const tests = paths.some((path) => FULL_TEST_TRIGGERS.has(path)) + ? { mode: "full", files: [] } + : scopedValidation(relatedTestInputs(paths), "related"); + + return { paths, lint, tests }; +} + +export function createValidationSteps(plan) { + const steps = [ + { + label: "cached whole-project typecheck", + npmArgs: ["run", "typecheck:cached"], + }, + { + label: "whole-project Knip analysis", + npmArgs: ["run", "knip"], + }, + ]; + + if (plan.lint.mode === "full") { + steps.push({ label: "full ESLint validation (configuration changed)", npmArgs: ["run", "lint"] }); + } else if (plan.lint.mode === "scoped") { + steps.push({ + label: `ESLint validation for ${String(plan.lint.files.length)} staged file(s)`, + npmArgs: ["exec", "--", "eslint", "--", ...plan.lint.files], + }); + } + + if (plan.tests.mode === "full") { + steps.push({ label: "full Vitest validation (configuration changed)", npmArgs: ["test"] }); + } else if (plan.tests.mode === "related") { + steps.push({ + label: `Vitest validation related to ${String(plan.tests.files.length)} staged input(s)`, + npmArgs: [ + "exec", + "--", + "vitest", + "related", + "--run", + "--config", + "vitest.config.ts", + "--passWithNoTests", + ...plan.tests.files, + ], + }); + } + + return steps; +} + +function readStagedPaths() { + const output = execFileSync( + "git", + ["diff", "--cached", "--name-only", "--diff-filter=ACMRD", "-z"], + { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }, + ); + return parseNullDelimitedPaths(output); +} + +function relatedTestInputs(paths) { + const inputs = new Set(); + + for (const path of paths) { + if (isRelatedSourcePath(path)) inputs.add(path); + + if (path.startsWith("docker/")) { + for (const test of DOCKER_TESTS) inputs.add(test); + } else if (path === "README.md" || path.startsWith("docs/")) { + inputs.add(DOCKER_DOCS_TEST); + } + + if (path.startsWith("pi-web-plugins/")) inputs.add(PLUGIN_PUBLIC_API_TEST); + } + + return [...inputs].sort(); +} + +function isLintablePath(path) { + if (LINTABLE_ROOT_FILES.has(path)) return true; + return path.endsWith(".ts") && LINTABLE_DIRECTORIES.some((directory) => path.startsWith(directory)); +} + +function isRelatedSourcePath(path) { + if (path === "plugin-api.d.ts") return true; + if (!/\.(?:[cm]?[jt]s|[jt]sx|json)$/u.test(path)) return false; + return RELATED_SOURCE_DIRECTORIES.some((directory) => path.startsWith(directory)); +} + +function normalizeRepoPath(path) { + return path.replaceAll("\\", "/").replace(/^\.\//u, ""); +} + +function scopedValidation(files, mode) { + return files.length > 0 ? { mode, files } : { mode: "skip", files: [] }; +} + +function runNpmStep(step) { + console.log(`\n[pre-commit] ${step.label}`); + const invocation = npmInvocation(step.npmArgs); + const result = spawnSync(invocation.command, invocation.args, { stdio: "inherit" }); + if (result.error !== undefined) throw result.error; + return result.status ?? 1; +} + +function npmInvocation(npmArgs) { + const npmExecPath = process.env["npm_execpath"]; + if (npmExecPath !== undefined && npmExecPath.length > 0) { + return { command: process.execPath, args: [npmExecPath, ...npmArgs] }; + } + return { + command: process.platform === "win32" ? "npm.cmd" : "npm", + args: npmArgs, + }; +} + +function main() { + const plan = createValidationPlan(readStagedPaths()); + console.log(`[pre-commit] Planning validation for ${String(plan.paths.length)} staged file(s).`); + + for (const step of createValidationSteps(plan)) { + const status = runNpmStep(step); + if (status !== 0) return status; + } + + if (plan.lint.mode === "skip") console.log("\n[pre-commit] No staged files require ESLint."); + if (plan.tests.mode === "skip") console.log("[pre-commit] No staged files have related Vitest coverage."); + return 0; +} + +function isDirectExecution() { + const entryPath = process.argv[1]; + if (entryPath === undefined) return false; + return pathToFileURL(resolve(entryPath)).href === import.meta.url; +} + +if (isDirectExecution()) { + try { + process.exitCode = main(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[pre-commit] ${message}`); + process.exitCode = 1; + } +} diff --git a/scripts/verify-staged.test.mjs b/scripts/verify-staged.test.mjs new file mode 100644 index 0000000..554dc05 --- /dev/null +++ b/scripts/verify-staged.test.mjs @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + createValidationPlan, + createValidationSteps, + parseNullDelimitedPaths, +} from "./verify-staged.mjs"; + +describe("staged validation planning", () => { + it("parses NUL-delimited Git paths without breaking spaces", () => { + expect(parseNullDelimitedPaths(Buffer.from("src/one.ts\0src/path with spaces/two.ts\0"))).toEqual([ + "src/one.ts", + "src/path with spaces/two.ts", + ]); + }); + + it("scopes ESLint and Vitest to staged source files", () => { + const plan = createValidationPlan([ + "src/client/src/components/ChatView.ts", + "src/client/src/components/ChatView.test.ts", + "README.md", + ], { pathExists: () => true }); + + expect(plan).toEqual({ + paths: [ + "README.md", + "src/client/src/components/ChatView.test.ts", + "src/client/src/components/ChatView.ts", + ], + lint: { + mode: "scoped", + files: [ + "src/client/src/components/ChatView.test.ts", + "src/client/src/components/ChatView.ts", + ], + }, + tests: { + mode: "related", + files: [ + "src/client/src/components/ChatView.test.ts", + "src/client/src/components/ChatView.ts", + "src/docker/piWebDockerDocs.test.ts", + ], + }, + }); + }); + + it("does not lint deleted files but still gives them to Vitest dependency analysis", () => { + const plan = createValidationPlan(["src/shared/deleted.ts"], { pathExists: () => false }); + + expect(plan.lint).toEqual({ mode: "skip", files: [] }); + expect(plan.tests).toEqual({ mode: "related", files: ["src/shared/deleted.ts"] }); + }); + + it("adds tests for repository assets that are read dynamically", () => { + const plan = createValidationPlan([ + "docker/internal/image/install-opensuse-base", + "pi-web-plugins/updates/updatesLogic.ts", + ], { pathExists: () => true }); + + expect(plan.tests).toEqual({ + mode: "related", + files: [ + "pi-web-plugins/pluginPublicApi.test.ts", + "pi-web-plugins/updates/updatesLogic.ts", + "src/docker/piWebDockerDocs.test.ts", + "src/docker/piWebDockerEntrypoint.test.ts", + "src/server/dockerControlAssets.test.ts", + ], + }); + }); + + it("runs only the affected full validator when its configuration changes", () => { + const eslintPlan = createValidationPlan(["eslint.config.js"], { pathExists: () => true }); + expect(eslintPlan.lint).toEqual({ mode: "full", files: [] }); + expect(eslintPlan.tests).toEqual({ mode: "skip", files: [] }); + + const vitestPlan = createValidationPlan(["vitest.config.ts"], { pathExists: () => true }); + expect(vitestPlan.lint).toEqual({ mode: "scoped", files: ["vitest.config.ts"] }); + expect(vitestPlan.tests).toEqual({ mode: "full", files: [] }); + + const typescriptPlan = createValidationPlan(["tsconfig.json"], { pathExists: () => true }); + expect(typescriptPlan.lint).toEqual({ mode: "full", files: [] }); + expect(typescriptPlan.tests).toEqual({ mode: "full", files: [] }); + }); + + it("always includes cached typechecking and Knip before scoped checks", () => { + const plan = createValidationPlan(["./src/path with spaces/example.ts"], { pathExists: () => true }); + + expect(createValidationSteps(plan).map((step) => step.npmArgs)).toEqual([ + ["run", "typecheck:cached"], + ["run", "knip"], + ["exec", "--", "eslint", "--", "src/path with spaces/example.ts"], + [ + "exec", + "--", + "vitest", + "related", + "--run", + "--config", + "vitest.config.ts", + "--passWithNoTests", + "src/path with spaces/example.ts", + ], + ]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index e8f564d..da9e63a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["src/**/*.test.ts", "pi-web-plugins/**/*.test.ts"], + include: ["src/**/*.test.ts", "pi-web-plugins/**/*.test.ts", "scripts/**/*.test.mjs"], }, }); From 1db2ae5a9f48a36c86c93c408bac6b03e7c7bb17 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 20:03:30 +0200 Subject: [PATCH 086/111] fix(ci): avoid Windows shebang parse failure --- scripts/verify-staged.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/verify-staged.mjs b/scripts/verify-staged.mjs index 37e4c06..daf09a0 100644 --- a/scripts/verify-staged.mjs +++ b/scripts/verify-staged.mjs @@ -1,4 +1,3 @@ -#!/usr/bin/env node import { execFileSync, spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; From 6213940eeac5cb33d006e5329a3b56088daa19f3 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 20:14:40 +0200 Subject: [PATCH 087/111] test: tolerate build graph contention --- src/buildContents.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/buildContents.test.ts b/src/buildContents.test.ts index 2ce3a53..b59cd6e 100644 --- a/src/buildContents.test.ts +++ b/src/buildContents.test.ts @@ -9,7 +9,8 @@ import { describe, expect, it } from "vitest"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); describe("production build contents", () => { - it("keeps test-support modules out of the TypeScript build graph", () => { + // Constructing the full compiler graph can exceed Vitest's default timeout under parallel-suite CPU contention. + it("keeps test-support modules out of the TypeScript build graph", { timeout: 15_000 }, () => { const buildConfig = readBuildConfig(); const program = ts.createProgram({ rootNames: buildConfig.fileNames, options: buildConfig.options }); const projectSources = program.getSourceFiles() From 256db336e3b9585e74386e1982d92d6ba685b4a6 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 20:24:33 +0200 Subject: [PATCH 088/111] chore(changesets): normalize pending release notes --- .changeset/bulk-session-mutations.md | 2 +- .changeset/clarify-subsession-waiting.md | 2 +- .changeset/clean-cross-platform-package-builds.md | 5 +++++ .changeset/docker-helper-inline-logs.md | 5 ----- .changeset/docker-image-vim.md | 5 ----- .changeset/docker-peer-pi-bin.md | 5 ----- .changeset/faster-chat-loading.md | 2 +- .changeset/federated-pi-packages.md | 5 ----- .changeset/fix-docker-installer-asset-fetch.md | 5 ----- .changeset/fuzzy-session-starts.md | 5 ----- .changeset/horizontal-tool-targets.md | 2 +- .changeset/manage-pi-packages.md | 2 +- .changeset/prompt-editor-stable-during-streaming.md | 2 +- .changeset/raise-min-pi-version-0-80.md | 2 +- .changeset/refresh-dependencies.md | 2 +- .changeset/relay-session-names.md | 2 +- .changeset/reliable-file-suggestions.md | 5 +++++ .changeset/responsive-package-settings.md | 5 ----- .changeset/selected-machine-settings.md | 2 +- .changeset/session-id-suffix-labels.md | 2 +- .changeset/session-runtime-reload-command.md | 2 +- .changeset/session-start-persistence.md | 2 +- .changeset/settings-panel-layout.md | 2 +- .changeset/show-complete-message-metadata.md | 2 +- .changeset/steady-fips-docker-builds.md | 5 ----- 25 files changed, 25 insertions(+), 55 deletions(-) create mode 100644 .changeset/clean-cross-platform-package-builds.md delete mode 100644 .changeset/docker-helper-inline-logs.md delete mode 100644 .changeset/docker-image-vim.md delete mode 100644 .changeset/docker-peer-pi-bin.md delete mode 100644 .changeset/federated-pi-packages.md delete mode 100644 .changeset/fix-docker-installer-asset-fetch.md delete mode 100644 .changeset/fuzzy-session-starts.md create mode 100644 .changeset/reliable-file-suggestions.md delete mode 100644 .changeset/responsive-package-settings.md delete mode 100644 .changeset/steady-fips-docker-builds.md diff --git a/.changeset/bulk-session-mutations.md b/.changeset/bulk-session-mutations.md index ac5beb8..5c51fee 100644 --- a/.changeset/bulk-session-mutations.md +++ b/.changeset/bulk-session-mutations.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Improve bulk session archive and delete reliability by adding true bulk mutation support for large session selections. +Make archive and delete actions reliable for large multi-session selections. diff --git a/.changeset/clarify-subsession-waiting.md b/.changeset/clarify-subsession-waiting.md index c76196c..f49d004 100644 --- a/.changeset/clarify-subsession-waiting.md +++ b/.changeset/clarify-subsession-waiting.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Keep delegation tools available to human-created and independently spawned sessions, remove them from tracked child sessions, and guide parent agents to track required subsessions and yield at a join point instead of polling. +Keep delegation tools available in human-created and independently spawned sessions, remove them from tracked child sessions, and guide parents to wait for required children at join points without polling. diff --git a/.changeset/clean-cross-platform-package-builds.md b/.changeset/clean-cross-platform-package-builds.md new file mode 100644 index 0000000..83d7676 --- /dev/null +++ b/.changeset/clean-cross-platform-package-builds.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep npm release builds working across platforms and exclude internal test-support modules from published packages. diff --git a/.changeset/docker-helper-inline-logs.md b/.changeset/docker-helper-inline-logs.md deleted file mode 100644 index 12d0b24..0000000 --- a/.changeset/docker-helper-inline-logs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Stream Docker update/restart helper logs inline after scheduling detached maintenance work. diff --git a/.changeset/docker-image-vim.md b/.changeset/docker-image-vim.md deleted file mode 100644 index 5c35fcf..0000000 --- a/.changeset/docker-image-vim.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Include Vim in the default Docker runtime and development images. diff --git a/.changeset/docker-peer-pi-bin.md b/.changeset/docker-peer-pi-bin.md deleted file mode 100644 index 5a3f96c..0000000 --- a/.changeset/docker-peer-pi-bin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Use PI WEB's npm peer dependency for the Docker Pi runtime and link the peer-provided `pi` binary instead of carrying a separate Pi package version setting. diff --git a/.changeset/faster-chat-loading.md b/.changeset/faster-chat-loading.md index 826311a..24752b9 100644 --- a/.changeset/faster-chat-loading.md +++ b/.changeset/faster-chat-loading.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Improve chat loading and resume performance by sharing duplicate session work, compressing browser responses, trimming unused thinking signatures, and lazily rendering closed technical-event groups. +Speed up chat loading, session resume, and long-conversation rendering while reducing browser response sizes. diff --git a/.changeset/federated-pi-packages.md b/.changeset/federated-pi-packages.md deleted file mode 100644 index 89153f5..0000000 --- a/.changeset/federated-pi-packages.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Manage Pi packages from Settings on the selected PI WEB machine, including federated remote machines, while keeping gateway-local Settings scopes clear. diff --git a/.changeset/fix-docker-installer-asset-fetch.md b/.changeset/fix-docker-installer-asset-fetch.md deleted file mode 100644 index 4186488..0000000 --- a/.changeset/fix-docker-installer-asset-fetch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Fix the Docker runtime installer so one-line installs can fetch Docker assets into a fresh install directory. diff --git a/.changeset/fuzzy-session-starts.md b/.changeset/fuzzy-session-starts.md deleted file mode 100644 index 48d9921..0000000 --- a/.changeset/fuzzy-session-starts.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Improve session-start feedback so concurrent new sessions stay visible without disrupting session-list navigation. diff --git a/.changeset/horizontal-tool-targets.md b/.changeset/horizontal-tool-targets.md index e57abff..001029e 100644 --- a/.changeset/horizontal-tool-targets.md +++ b/.changeset/horizontal-tool-targets.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Show full chat tool file paths and commands in horizontally scrollable headers, repeat them in expanded tool details, and keep result output horizontally scrollable. +Show complete file paths and commands in tool headers and expanded details, with horizontal scrolling for long tool targets and results. diff --git a/.changeset/manage-pi-packages.md b/.changeset/manage-pi-packages.md index 46bd28d..ab326c1 100644 --- a/.changeset/manage-pi-packages.md +++ b/.changeset/manage-pi-packages.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Add PI WEB settings for managing Pi packages separately from PI WEB plugin enablement, including install/remove/update flows and browser/session reload guidance. +Manage Pi packages from Settings on the selected local or federated PI WEB machine, with install, update, and removal flows that respect each machine's advertised capabilities. diff --git a/.changeset/prompt-editor-stable-during-streaming.md b/.changeset/prompt-editor-stable-during-streaming.md index a4bc709..bb35fcb 100644 --- a/.changeset/prompt-editor-stable-during-streaming.md +++ b/.changeset/prompt-editor-stable-during-streaming.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Keep the chat prompt input stable during streaming so mobile touch gestures (such as the iOS long-press paste/edit callout) are no longer interrupted. Session status and activity updates are now coalesced into a single render per animation frame instead of one per token, the prompt editor ignores status changes that do not affect what it displays, and per-keystroke draft state no longer triggers surrounding re-renders. +Keep the chat prompt stable during streaming so mobile touch gestures, including iOS paste and edit callouts, are not interrupted. diff --git a/.changeset/raise-min-pi-version-0-80.md b/.changeset/raise-min-pi-version-0-80.md index 91a4e3a..63a4527 100644 --- a/.changeset/raise-min-pi-version-0-80.md +++ b/.changeset/raise-min-pi-version-0-80.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Raise the minimum supported Pi version to 0.80, removing reliance on Pi's deprecated `pi-ai` compat API for session-name generation in favor of the stable `pi-agent-core` streaming interface. +Require Pi 0.80 or newer and use its stable streaming API for session-name generation. diff --git a/.changeset/refresh-dependencies.md b/.changeset/refresh-dependencies.md index 9143754..072a7c0 100644 --- a/.changeset/refresh-dependencies.md +++ b/.changeset/refresh-dependencies.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Update runtime and development dependencies, including Pi 0.80.6 support and the `max` thinking level. +Support Pi's `max` thinking level and refresh shipped runtime dependencies. diff --git a/.changeset/relay-session-names.md b/.changeset/relay-session-names.md index 0d0275a..b478d17 100644 --- a/.changeset/relay-session-names.md +++ b/.changeset/relay-session-names.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Name Relay handoff sessions deterministically from their relay name and leg number. +Name Relay handoff sessions consistently from their relay name and leg number. diff --git a/.changeset/reliable-file-suggestions.md b/.changeset/reliable-file-suggestions.md new file mode 100644 index 0000000..f531824 --- /dev/null +++ b/.changeset/reliable-file-suggestions.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Improve file suggestions by waiting for all Git probes before deciding whether to scan the wider workspace. diff --git a/.changeset/responsive-package-settings.md b/.changeset/responsive-package-settings.md deleted file mode 100644 index d1f9e6d..0000000 --- a/.changeset/responsive-package-settings.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Keep gateway Settings panels responsive while selected-machine Pi packages load or fail separately, report Pi package-management support through runtime capabilities, guide remote Pi package-management UI when support is known unavailable, and serialize Pi package mutations to avoid concurrent settings/install-root races within a PI WEB server process. diff --git a/.changeset/selected-machine-settings.md b/.changeset/selected-machine-settings.md index 0c8161b..d4a6d6d 100644 --- a/.changeset/selected-machine-settings.md +++ b/.changeset/selected-machine-settings.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Make Settings edit machine-scoped PI WEB config on the selected machine for session daemon tools, plugin enablement, external path access, and upload defaults while keeping gateway/browser-only settings local, and show those forms as unavailable when a remote machine does not advertise support. +Edit machine-scoped PI WEB settings on the selected machine—including session daemon tools, plugin enablement, path access, and upload defaults—while keeping gateway/browser-only settings local and disabling unsupported remote forms. diff --git a/.changeset/session-id-suffix-labels.md b/.changeset/session-id-suffix-labels.md index 4474f2f..c9808c9 100644 --- a/.changeset/session-id-suffix-labels.md +++ b/.changeset/session-id-suffix-labels.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Show the random-looking suffix for unnamed sessions so newly created empty sessions are easier to distinguish. +Show generated suffixes for unnamed sessions so multiple new empty chats are easier to distinguish. diff --git a/.changeset/session-runtime-reload-command.md b/.changeset/session-runtime-reload-command.md index c352987..92966e1 100644 --- a/.changeset/session-runtime-reload-command.md +++ b/.changeset/session-runtime-reload-command.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Add `/reload` support for PI WEB sessions so installed Pi package resources can be refreshed in existing sessions without restarting the session daemon, while keeping browser plugin reload guidance separate. +Add `/reload` for PI WEB sessions so newly installed Pi package resources can be loaded without restarting the session daemon, with separate guidance for browser plugin reloads. diff --git a/.changeset/session-start-persistence.md b/.changeset/session-start-persistence.md index 33e8be4..ea9afe1 100644 --- a/.changeset/session-start-persistence.md +++ b/.changeset/session-start-persistence.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Create editable chats immediately when starting sessions, open the chat right away on mobile, queue sends until the backend session is ready, reconcile concurrent session-created broadcasts, and use server-backed persistence signals for session archive/delete/reload actions. +Open new chats immediately—including on mobile—queue sends until their backend sessions are ready, and keep concurrent starts and archive/delete/reload actions aligned with server persistence. diff --git a/.changeset/settings-panel-layout.md b/.changeset/settings-panel-layout.md index 0135be0..2295ce4 100644 --- a/.changeset/settings-panel-layout.md +++ b/.changeset/settings-panel-layout.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Standardize Settings tabs so descriptions, notices, and controls render in a consistent order, with unavailable remote settings hiding blocked controls. +Standardize Settings panels so descriptions, notices, and controls render in a consistent order. diff --git a/.changeset/show-complete-message-metadata.md b/.changeset/show-complete-message-metadata.md index 61723f5..03dfc2a 100644 --- a/.changeset/show-complete-message-metadata.md +++ b/.changeset/show-complete-message-metadata.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Show complete chat message dates and model identifiers in one consistent label, wrap rather than truncate expanded metadata, and keep the touch info control compact without changing message-header height. +Show complete message dates and model identifiers in a consistent label, wrapping expanded metadata without changing message-header height. diff --git a/.changeset/steady-fips-docker-builds.md b/.changeset/steady-fips-docker-builds.md deleted file mode 100644 index 48c7cc9..0000000 --- a/.changeset/steady-fips-docker-builds.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Avoid interactive openSUSE FIPS crypto-policy solver conflicts during Docker image builds. From 16b801b8cdac0143554c56281232902a0a1c4e66 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 20:47:05 +0200 Subject: [PATCH 089/111] chore(release): v1.202607.0 --- .changeset/bulk-session-mutations.md | 5 ---- .changeset/chat-copy-insecure-contexts.md | 5 ---- .changeset/clarify-subsession-waiting.md | 5 ---- .../clean-cross-platform-package-builds.md | 5 ---- .changeset/faster-chat-loading.md | 5 ---- .changeset/horizontal-tool-targets.md | 5 ---- ...rit-dispatch-model-for-spawned-sessions.md | 5 ---- .../legacy-federated-session-actions.md | 5 ---- .changeset/manage-pi-packages.md | 5 ---- .../prompt-editor-stable-during-streaming.md | 5 ---- .changeset/quiet-ios-input-zoom.md | 5 ---- .changeset/raise-min-pi-version-0-80.md | 5 ---- .changeset/refresh-dependencies.md | 5 ---- .changeset/relay-session-names.md | 5 ---- .changeset/reliable-file-suggestions.md | 5 ---- .changeset/remove-updates-beta-badge.md | 5 ---- .changeset/selected-machine-settings.md | 5 ---- .changeset/session-id-suffix-labels.md | 5 ---- .changeset/session-runtime-reload-command.md | 5 ---- .changeset/session-start-persistence.md | 5 ---- .changeset/settings-panel-layout.md | 5 ---- .changeset/show-complete-message-metadata.md | 5 ---- .changeset/terminal-copy-mode.md | 5 ---- CHANGELOG.md | 28 +++++++++++++++++++ package-lock.json | 4 +-- package.json | 2 +- 26 files changed, 31 insertions(+), 118 deletions(-) delete mode 100644 .changeset/bulk-session-mutations.md delete mode 100644 .changeset/chat-copy-insecure-contexts.md delete mode 100644 .changeset/clarify-subsession-waiting.md delete mode 100644 .changeset/clean-cross-platform-package-builds.md delete mode 100644 .changeset/faster-chat-loading.md delete mode 100644 .changeset/horizontal-tool-targets.md delete mode 100644 .changeset/inherit-dispatch-model-for-spawned-sessions.md delete mode 100644 .changeset/legacy-federated-session-actions.md delete mode 100644 .changeset/manage-pi-packages.md delete mode 100644 .changeset/prompt-editor-stable-during-streaming.md delete mode 100644 .changeset/quiet-ios-input-zoom.md delete mode 100644 .changeset/raise-min-pi-version-0-80.md delete mode 100644 .changeset/refresh-dependencies.md delete mode 100644 .changeset/relay-session-names.md delete mode 100644 .changeset/reliable-file-suggestions.md delete mode 100644 .changeset/remove-updates-beta-badge.md delete mode 100644 .changeset/selected-machine-settings.md delete mode 100644 .changeset/session-id-suffix-labels.md delete mode 100644 .changeset/session-runtime-reload-command.md delete mode 100644 .changeset/session-start-persistence.md delete mode 100644 .changeset/settings-panel-layout.md delete mode 100644 .changeset/show-complete-message-metadata.md delete mode 100644 .changeset/terminal-copy-mode.md diff --git a/.changeset/bulk-session-mutations.md b/.changeset/bulk-session-mutations.md deleted file mode 100644 index 5c51fee..0000000 --- a/.changeset/bulk-session-mutations.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Make archive and delete actions reliable for large multi-session selections. diff --git a/.changeset/chat-copy-insecure-contexts.md b/.changeset/chat-copy-insecure-contexts.md deleted file mode 100644 index 67af395..0000000 --- a/.changeset/chat-copy-insecure-contexts.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Allow chat copy buttons to work from HTTP private-network addresses by falling back when the browser Clipboard API is unavailable. diff --git a/.changeset/clarify-subsession-waiting.md b/.changeset/clarify-subsession-waiting.md deleted file mode 100644 index f49d004..0000000 --- a/.changeset/clarify-subsession-waiting.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Keep delegation tools available in human-created and independently spawned sessions, remove them from tracked child sessions, and guide parents to wait for required children at join points without polling. diff --git a/.changeset/clean-cross-platform-package-builds.md b/.changeset/clean-cross-platform-package-builds.md deleted file mode 100644 index 83d7676..0000000 --- a/.changeset/clean-cross-platform-package-builds.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Keep npm release builds working across platforms and exclude internal test-support modules from published packages. diff --git a/.changeset/faster-chat-loading.md b/.changeset/faster-chat-loading.md deleted file mode 100644 index 24752b9..0000000 --- a/.changeset/faster-chat-loading.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Speed up chat loading, session resume, and long-conversation rendering while reducing browser response sizes. diff --git a/.changeset/horizontal-tool-targets.md b/.changeset/horizontal-tool-targets.md deleted file mode 100644 index 001029e..0000000 --- a/.changeset/horizontal-tool-targets.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Show complete file paths and commands in tool headers and expanded details, with horizontal scrolling for long tool targets and results. diff --git a/.changeset/inherit-dispatch-model-for-spawned-sessions.md b/.changeset/inherit-dispatch-model-for-spawned-sessions.md deleted file mode 100644 index c2d9a4b..0000000 --- a/.changeset/inherit-dispatch-model-for-spawned-sessions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Make spawned and tracked subsessions inherit the dispatching session's current model instead of falling back to the last globally selected model. diff --git a/.changeset/legacy-federated-session-actions.md b/.changeset/legacy-federated-session-actions.md deleted file mode 100644 index 45098d9..0000000 --- a/.changeset/legacy-federated-session-actions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Preserve archive and archived-session delete actions for older federated PI WEB machines that do not yet advertise session persistence or delete capabilities. diff --git a/.changeset/manage-pi-packages.md b/.changeset/manage-pi-packages.md deleted file mode 100644 index ab326c1..0000000 --- a/.changeset/manage-pi-packages.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Manage Pi packages from Settings on the selected local or federated PI WEB machine, with install, update, and removal flows that respect each machine's advertised capabilities. diff --git a/.changeset/prompt-editor-stable-during-streaming.md b/.changeset/prompt-editor-stable-during-streaming.md deleted file mode 100644 index bb35fcb..0000000 --- a/.changeset/prompt-editor-stable-during-streaming.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Keep the chat prompt stable during streaming so mobile touch gestures, including iOS paste and edit callouts, are not interrupted. diff --git a/.changeset/quiet-ios-input-zoom.md b/.changeset/quiet-ios-input-zoom.md deleted file mode 100644 index 90adbfb..0000000 --- a/.changeset/quiet-ios-input-zoom.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Prevent iOS Safari from zooming into small text inputs across the web UI. diff --git a/.changeset/raise-min-pi-version-0-80.md b/.changeset/raise-min-pi-version-0-80.md deleted file mode 100644 index 63a4527..0000000 --- a/.changeset/raise-min-pi-version-0-80.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Require Pi 0.80 or newer and use its stable streaming API for session-name generation. diff --git a/.changeset/refresh-dependencies.md b/.changeset/refresh-dependencies.md deleted file mode 100644 index 072a7c0..0000000 --- a/.changeset/refresh-dependencies.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Support Pi's `max` thinking level and refresh shipped runtime dependencies. diff --git a/.changeset/relay-session-names.md b/.changeset/relay-session-names.md deleted file mode 100644 index b478d17..0000000 --- a/.changeset/relay-session-names.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Name Relay handoff sessions consistently from their relay name and leg number. diff --git a/.changeset/reliable-file-suggestions.md b/.changeset/reliable-file-suggestions.md deleted file mode 100644 index f531824..0000000 --- a/.changeset/reliable-file-suggestions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Improve file suggestions by waiting for all Git probes before deciding whether to scan the wider workspace. diff --git a/.changeset/remove-updates-beta-badge.md b/.changeset/remove-updates-beta-badge.md deleted file mode 100644 index 31ebee3..0000000 --- a/.changeset/remove-updates-beta-badge.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Promote the Updates tab to stable by removing its beta label while keeping update message counts visible. diff --git a/.changeset/selected-machine-settings.md b/.changeset/selected-machine-settings.md deleted file mode 100644 index d4a6d6d..0000000 --- a/.changeset/selected-machine-settings.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Edit machine-scoped PI WEB settings on the selected machine—including session daemon tools, plugin enablement, path access, and upload defaults—while keeping gateway/browser-only settings local and disabling unsupported remote forms. diff --git a/.changeset/session-id-suffix-labels.md b/.changeset/session-id-suffix-labels.md deleted file mode 100644 index c9808c9..0000000 --- a/.changeset/session-id-suffix-labels.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Show generated suffixes for unnamed sessions so multiple new empty chats are easier to distinguish. diff --git a/.changeset/session-runtime-reload-command.md b/.changeset/session-runtime-reload-command.md deleted file mode 100644 index 92966e1..0000000 --- a/.changeset/session-runtime-reload-command.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add `/reload` for PI WEB sessions so newly installed Pi package resources can be loaded without restarting the session daemon, with separate guidance for browser plugin reloads. diff --git a/.changeset/session-start-persistence.md b/.changeset/session-start-persistence.md deleted file mode 100644 index ea9afe1..0000000 --- a/.changeset/session-start-persistence.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Open new chats immediately—including on mobile—queue sends until their backend sessions are ready, and keep concurrent starts and archive/delete/reload actions aligned with server persistence. diff --git a/.changeset/settings-panel-layout.md b/.changeset/settings-panel-layout.md deleted file mode 100644 index 2295ce4..0000000 --- a/.changeset/settings-panel-layout.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Standardize Settings panels so descriptions, notices, and controls render in a consistent order. diff --git a/.changeset/show-complete-message-metadata.md b/.changeset/show-complete-message-metadata.md deleted file mode 100644 index 03dfc2a..0000000 --- a/.changeset/show-complete-message-metadata.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Show complete message dates and model identifiers in a consistent label, wrapping expanded metadata without changing message-header height. diff --git a/.changeset/terminal-copy-mode.md b/.changeset/terminal-copy-mode.md deleted file mode 100644 index 2c99cd8..0000000 --- a/.changeset/terminal-copy-mode.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add a terminal copy mode with a touch-selectable, color-preserving output snapshot and a Copy all action for mobile browsers. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6393e2e..9c1fba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # @jmfederico/pi-web +## 1.202607.0 + +### Patch Changes + +- d165d69: Make archive and delete actions reliable for large multi-session selections. +- d6cfffd: Allow chat copy buttons to work from HTTP private-network addresses by falling back when the browser Clipboard API is unavailable. +- a660ba8: Keep delegation tools available in human-created and independently spawned sessions, remove them from tracked child sessions, and guide parents to wait for required children at join points without polling. +- 256db33: Keep npm release builds working across platforms and exclude internal test-support modules from published packages. +- 338faf4: Speed up chat loading, session resume, and long-conversation rendering while reducing browser response sizes. +- ad62853: Show complete file paths and commands in tool headers and expanded details, with horizontal scrolling for long tool targets and results. +- a874798: Make spawned and tracked subsessions inherit the dispatching session's current model instead of falling back to the last globally selected model. +- eb17276: Preserve archive and archived-session delete actions for older federated PI WEB machines that do not yet advertise session persistence or delete capabilities. +- 8ade238: Manage Pi packages from Settings on the selected local or federated PI WEB machine, with install, update, and removal flows that respect each machine's advertised capabilities. +- 2009e6a: Keep the chat prompt stable during streaming so mobile touch gestures, including iOS paste and edit callouts, are not interrupted. +- 7063c2c: Prevent iOS Safari from zooming into small text inputs across the web UI. +- 386c67e: Require Pi 0.80 or newer and use its stable streaming API for session-name generation. +- 32907bb: Support Pi's `max` thinking level and refresh shipped runtime dependencies. +- 10efb7f: Name Relay handoff sessions consistently from their relay name and leg number. +- 256db33: Improve file suggestions by waiting for all Git probes before deciding whether to scan the wider workspace. +- 0b17b9d: Promote the Updates tab to stable by removing its beta label while keeping update message counts visible. +- 64b2b32: Edit machine-scoped PI WEB settings on the selected machine—including session daemon tools, plugin enablement, path access, and upload defaults—while keeping gateway/browser-only settings local and disabling unsupported remote forms. +- d2e10cd: Show generated suffixes for unnamed sessions so multiple new empty chats are easier to distinguish. +- 889672f: Add `/reload` for PI WEB sessions so newly installed Pi package resources can be loaded without restarting the session daemon, with separate guidance for browser plugin reloads. +- 2665d1e: Open new chats immediately—including on mobile—queue sends until their backend sessions are ready, and keep concurrent starts and archive/delete/reload actions aligned with server persistence. +- b61a9c0: Standardize Settings panels so descriptions, notices, and controls render in a consistent order. +- abcf44b: Show complete message dates and model identifiers in a consistent label, wrapping expanded metadata without changing message-header height. +- 02f34c4: Add a terminal copy mode with a touch-selectable, color-preserving output snapshot and a Copy all action for mobile browsers. + ## 1.202606.7 ### Patch Changes diff --git a/package-lock.json b/package-lock.json index 8a79114..446d3e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jmfederico/pi-web", - "version": "1.202606.7", + "version": "1.202607.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jmfederico/pi-web", - "version": "1.202606.7", + "version": "1.202607.0", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.10.4", diff --git a/package.json b/package.json index 01370a4..e84997d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jmfederico/pi-web", - "version": "1.202606.7", + "version": "1.202607.0", "description": "Web UI for persistent Pi Coding Agent sessions in real workspaces.", "license": "MIT", "author": "Federico Jaramillo Martinez", From 21c58fe6560db74af889610884a29ef9557ea4fc Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 22:39:41 +0200 Subject: [PATCH 090/111] fix: serve plugin SVG assets with correct MIME type --- .changeset/serve-plugin-svg-assets.md | 5 +++++ docs/plugins.md | 10 +++++++++- src/server/app.plugins.test.ts | 5 +++++ src/server/app.testSupport.ts | 9 ++++++++- src/server/piWebPluginService.test.ts | 22 ++++++++++++++++++++++ src/server/piWebPluginService.ts | 15 +++++++++------ 6 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 .changeset/serve-plugin-svg-assets.md diff --git a/.changeset/serve-plugin-svg-assets.md b/.changeset/serve-plugin-svg-assets.md new file mode 100644 index 0000000..a17dea4 --- /dev/null +++ b/.changeset/serve-plugin-svg-assets.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Serve PI WEB plugin SVG assets with a browser-compatible content type and clarify module-relative asset packaging. diff --git a/docs/plugins.md b/docs/plugins.md index dd08ba6..77452eb 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -347,7 +347,15 @@ A plugin can fetch its own static assets with URLs under: /pi-web-plugins// ``` -PI WEB prevents asset path traversal outside the plugin root. JavaScript, JSON, CSS, and HTML get appropriate content types; other files are served as octet-stream. +Prefer module-relative asset URLs so they also work for remote machine plugins. For example, a built plugin module can reference an SVG shipped beside it: + +```js +const iconUrl = new URL("./assets/icon.svg", import.meta.url); +``` + +The final installed plugin package must contain `assets/icon.svg` at that path relative to the final built module. PI WEB serves files that already exist in the package; it does not copy a source `public/` directory or apply Vite-style public-directory semantics. Configure the plugin build and package contents to emit or copy the asset into its final module-relative location. + +PI WEB prevents asset path traversal outside the plugin root. JavaScript, JSON, CSS, HTML, and SVG files get appropriate content types; unknown file types are served as octet-stream. ## Plugin module shape diff --git a/src/server/app.plugins.test.ts b/src/server/app.plugins.test.ts index 1969335..d4e4d8e 100644 --- a/src/server/app.plugins.test.ts +++ b/src/server/app.plugins.test.ts @@ -24,6 +24,11 @@ describe("buildApp PI WEB plugin routes", () => { expect(assetResponse.headers["content-type"]).toContain("application/javascript"); expect(assetResponse.body).toBe("export default {};"); + const svgResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/assets/icon.svg" }); + expect(svgResponse.statusCode).toBe(200); + expect(svgResponse.headers["content-type"]).toContain("image/svg+xml"); + expect(svgResponse.body).toContain(" Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }), plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }), - readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined), + readAsset: fakePiWebPluginAsset, }, clientDist: false, logger: false, @@ -123,6 +123,13 @@ export function registerAppTestHooks(): void { }); } +function fakePiWebPluginAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> { + if (pluginId !== "fake") return Promise.resolve(undefined); + if (assetPath === "plugin.js") return Promise.resolve({ content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" }); + if (assetPath === "assets/icon.svg") return Promise.resolve({ content: Buffer.from(''), contentType: "image/svg+xml" }); + return Promise.resolve(undefined); +} + export interface CapturedSessionDaemonRequest { method: string; path: string; diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 733cfe0..7507aa7 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -44,6 +44,28 @@ describe("PiWebPluginService", () => { expect(asset?.content.toString("utf8")).toContain("export default"); }); + it("serves nested SVG assets with a browser-compatible content type", async () => { + const pluginDir = join(tempDir, "plugins", "icons"); + const svg = ''; + await writePlugin(pluginDir, { + packageJson: { piWeb: { plugins: [{ id: "icons", module: "pi-web-plugin.js" }] } }, + files: { + "pi-web-plugin.js": "export default {};", + "assets/icon.svg": svg, + "assets/uppercase.SVG": svg, + "assets/data.bin": "unknown", + }, + }); + + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + + const svgAsset = await service.readAsset("icons", "assets/icon.svg"); + expect(svgAsset?.contentType).toBe("image/svg+xml"); + expect(svgAsset?.content.toString("utf8")).toBe(svg); + await expect(service.readAsset("icons", "assets/uppercase.SVG")).resolves.toMatchObject({ contentType: "image/svg+xml" }); + await expect(service.readAsset("icons", "assets/data.bin")).resolves.toMatchObject({ contentType: "application/octet-stream" }); + }); + it("includes machine-specific preferences in plugin manifests", async () => { await writePlugin(join(tempDir, "plugins", "updates"), { packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } }, diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index 563315e..0566153 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { readdir, readFile, realpath, stat } from "node:fs/promises"; -import { dirname, join, relative, resolve, sep } from "node:path"; +import { dirname, extname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; import { loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js"; @@ -335,11 +335,14 @@ function isWithin(root: string, candidate: string): boolean { } function contentTypeFor(path: string): string { - if (path.endsWith(".js")) return "application/javascript; charset=utf-8"; - if (path.endsWith(".json")) return "application/json; charset=utf-8"; - if (path.endsWith(".css")) return "text/css; charset=utf-8"; - if (path.endsWith(".html")) return "text/html; charset=utf-8"; - return "application/octet-stream"; + switch (extname(path).toLowerCase()) { + case ".js": return "application/javascript; charset=utf-8"; + case ".json": return "application/json; charset=utf-8"; + case ".css": return "text/css; charset=utf-8"; + case ".html": return "text/html; charset=utf-8"; + case ".svg": return "image/svg+xml"; + default: return "application/octet-stream"; + } } function isRecord(value: unknown): value is Record { From f9c0fed5e31064f65187c90f101bbf423d78b451 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 23:07:16 +0200 Subject: [PATCH 091/111] fix: preserve extension-only plugin asset MIME types --- src/server/piWebPluginService.test.ts | 16 ++++++++++++++++ src/server/piWebPluginService.ts | 17 ++++++++--------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 7507aa7..6b00d34 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -44,6 +44,22 @@ describe("PiWebPluginService", () => { expect(asset?.content.toString("utf8")).toContain("export default"); }); + it("preserves content types for extension-only asset names", async () => { + const pluginDir = join(tempDir, "plugins", "extension-only"); + await writePlugin(pluginDir, { + packageJson: { piWeb: { plugins: [{ id: "extension-only", module: ".js" }] } }, + files: { + ".js": "export default {};", + ".svg": '', + }, + }); + + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + + await expect(service.readAsset("extension-only", ".js")).resolves.toMatchObject({ contentType: "application/javascript; charset=utf-8" }); + await expect(service.readAsset("extension-only", ".svg")).resolves.toMatchObject({ contentType: "image/svg+xml" }); + }); + it("serves nested SVG assets with a browser-compatible content type", async () => { const pluginDir = join(tempDir, "plugins", "icons"); const svg = ''; diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index 0566153..45938ea 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { readdir, readFile, realpath, stat } from "node:fs/promises"; -import { dirname, extname, join, relative, resolve, sep } from "node:path"; +import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; import { loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js"; @@ -335,14 +335,13 @@ function isWithin(root: string, candidate: string): boolean { } function contentTypeFor(path: string): string { - switch (extname(path).toLowerCase()) { - case ".js": return "application/javascript; charset=utf-8"; - case ".json": return "application/json; charset=utf-8"; - case ".css": return "text/css; charset=utf-8"; - case ".html": return "text/html; charset=utf-8"; - case ".svg": return "image/svg+xml"; - default: return "application/octet-stream"; - } + const lowerPath = path.toLowerCase(); + if (lowerPath.endsWith(".js")) return "application/javascript; charset=utf-8"; + if (lowerPath.endsWith(".json")) return "application/json; charset=utf-8"; + if (lowerPath.endsWith(".css")) return "text/css; charset=utf-8"; + if (lowerPath.endsWith(".html")) return "text/html; charset=utf-8"; + if (lowerPath.endsWith(".svg")) return "image/svg+xml"; + return "application/octet-stream"; } function isRecord(value: unknown): value is Record { From d72b14f40ab0ac18a518d0d7183671000e980469 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 23:21:43 +0200 Subject: [PATCH 092/111] feat: add manual PI WEB update checks --- .changeset/fresh-machine-update-checks.md | 5 + docs/plugins.html | 7 +- docs/plugins.md | 8 +- pi-web-plugins/updates/pi-web-plugin.test.ts | 52 +++++++ pi-web-plugins/updates/pi-web-plugin.ts | 12 ++ src/client/src/api/clients.test.ts | 46 ++++-- src/client/src/api/clients.ts | 7 +- .../src/api/federatedRouteContract.test.ts | 1 + src/client/src/components/PiWebApp.ts | 22 ++- .../controllers/piWebStatusController.test.ts | 143 ++++++++++++++++++ .../src/controllers/piWebStatusController.ts | 68 +++++++++ src/client/src/plugins/types.ts | 1 + src/plugin-api.ts | 2 + src/server/app.piWebStatus.test.ts | 38 +++++ src/server/app.remoteProxy.test.ts | 17 +++ src/server/app.ts | 14 +- src/server/piWebReleaseLookupCache.test.ts | 80 ++++++++++ src/server/piWebReleaseLookupCache.ts | 57 +++++++ src/server/piWebStatus.test.ts | 33 +++- src/server/piWebStatus.ts | 26 ++-- src/server/piWebStatusCache.test.ts | 41 ++++- src/server/piWebStatusCache.ts | 36 +++-- 22 files changed, 653 insertions(+), 63 deletions(-) create mode 100644 .changeset/fresh-machine-update-checks.md create mode 100644 pi-web-plugins/updates/pi-web-plugin.test.ts create mode 100644 src/client/src/controllers/piWebStatusController.test.ts create mode 100644 src/client/src/controllers/piWebStatusController.ts create mode 100644 src/server/app.piWebStatus.test.ts create mode 100644 src/server/piWebReleaseLookupCache.test.ts create mode 100644 src/server/piWebReleaseLookupCache.ts diff --git a/.changeset/fresh-machine-update-checks.md b/.changeset/fresh-machine-update-checks.md new file mode 100644 index 0000000..3676170 --- /dev/null +++ b/.changeset/fresh-machine-update-checks.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a **Check for PI WEB Updates** action that bypasses cached release data and refreshes update status for the selected local or federated machine. diff --git a/docs/plugins.html b/docs/plugins.html index 89ea47b..543ab89 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -250,11 +250,14 @@ After editing, check the manifest endpoint and browser-console failure cases.Updates

    Updates adds a conditional Updates workspace tab with PI WEB update, - restart, and installed-service guidance. It is built into PI WEB, enabled by default, and uses the - selected machine's plugin copy when machine federation is active. + restart, and installed-service guidance, plus a Check for PI WEB Updates action. It is + built into PI WEB, enabled by default, and uses the selected machine's plugin copy when machine + federation is active.

    • Plugin id: updates
    • +
    • Selected-machine status refreshes every 15 minutes while a browser tab is connected.
    • +
    • Automatic npm release lookups are cached for six hours; the action bypasses the caches and checks immediately.
    diff --git a/docs/plugins.md b/docs/plugins.md index 77452eb..9a353df 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -202,9 +202,11 @@ Built-in plugins can be managed from **Settings → PI WEB plugins** or with the ### Updates **Plugin id:** `updates` -**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance. +**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance, plus a **Check for PI WEB Updates** action for the selected machine. -Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab only appears for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → PI WEB plugins** or set: +While a browser tab is connected, PI WEB refreshes the selected machine's status every 15 minutes. npm release lookups are cached on that machine for six hours, so the automatic refresh normally contacts npm at most once in that window. Run **Check for PI WEB Updates** from the action palette to bypass both caches and check immediately. Operator settings that skip remote version checks, such as `PI_WEB_OFFLINE`, are still respected. + +Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab and action only appear for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → PI WEB plugins** or set: ```json { @@ -474,6 +476,7 @@ interface PluginRuntimeContext { openTerminal: (options?: { terminalId?: string }) => void; refreshFiles: () => void | Promise; refreshGit: () => void | Promise; + checkForPiWebUpdates?: () => void | Promise; startSession: () => void | Promise; archiveSession: () => void | Promise; stopActiveWork: () => void | Promise; @@ -488,6 +491,7 @@ Notes: - `enabled` is evaluated when the action palette asks for actions. - `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`. - `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal. +- `checkForPiWebUpdates()` forces a fresh update check on the selected machine and refreshes `state.piWebStatus`. It is optional so plugins remain compatible with older PI WEB hosts. - Only fields documented here and declared in `plugin-api.d.ts` are stable public plugin API. Anything else is experimental: it may become public API later, change shape, or disappear. ### Prompt editor API diff --git a/pi-web-plugins/updates/pi-web-plugin.test.ts b/pi-web-plugins/updates/pi-web-plugin.test.ts new file mode 100644 index 0000000..b6638b4 --- /dev/null +++ b/pi-web-plugins/updates/pi-web-plugin.test.ts @@ -0,0 +1,52 @@ +import { html, svg } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import type { PluginRuntimeContext } from "@jmfederico/pi-web/plugin-api"; +import plugin from "./pi-web-plugin.js"; + +describe("Updates plugin actions", () => { + it("forces an update check through the host runtime context", async () => { + const action = plugin.activate({ apiVersion: 1, pluginId: "updates", html, svg }).contributions.actions?.find((candidate) => candidate.id === "check"); + if (action === undefined) throw new Error("Expected update check action"); + const checkForPiWebUpdates = vi.fn(() => Promise.resolve()); + const context = runtimeContext({ checkForPiWebUpdates }); + + expect(action.enabled?.(context)).toBe(true); + await action.run(context); + + expect(checkForPiWebUpdates).toHaveBeenCalledOnce(); + }); + + it("disables the action on older hosts without the update-check helper", () => { + const action = plugin.activate({ apiVersion: 1, pluginId: "updates", html, svg }).contributions.actions?.find((candidate) => candidate.id === "check"); + if (action === undefined) throw new Error("Expected update check action"); + const context = runtimeContext(); + + expect(action.enabled?.(context)).toBe(false); + expect(action.disabledReason?.(context)).toContain("newer PI WEB gateway"); + }); +}); + +function runtimeContext(patch: Partial = {}): PluginRuntimeContext { + const noop = () => undefined; + return { + state: {}, + prompt: { insertText: noop, getText: () => "", getSelection: () => null }, + openActionPalette: noop, + focusPrompt: noop, + addProject: noop, + configureAuth: noop, + logoutAuth: noop, + openThemePicker: noop, + selectMainView: noop, + selectWorkspaceTool: noop, + openTerminal: noop, + refreshFiles: noop, + refreshGit: noop, + refreshAppData: noop, + reloadPage: noop, + startSession: noop, + archiveSession: noop, + stopActiveWork: noop, + ...patch, + }; +} diff --git a/pi-web-plugins/updates/pi-web-plugin.ts b/pi-web-plugins/updates/pi-web-plugin.ts index e2aa7f4..1078660 100644 --- a/pi-web-plugins/updates/pi-web-plugin.ts +++ b/pi-web-plugins/updates/pi-web-plugin.ts @@ -143,6 +143,7 @@ function renderUpdatesPanel(html: HtmlTemplateTag, terminal: WorkspacePanelTermi
    Generated ${status.generatedAt} ${status.release.latestVersion === undefined ? null : html`Latest npm release ${status.release.latestVersion}`} + ${status.release.checkedAt === undefined || status.release.skipped === true ? null : html`Release checked ${status.release.checkedAt}`} ${status.release.skipped === true ? html`Remote version check skipped.` : null} ${status.release.error === undefined ? null : html`Remote version check failed: ${status.release.error}`}
    @@ -155,6 +156,17 @@ const plugin: PiWebPlugin = { name: "Updates", activate: ({ html, svg }) => ({ contributions: { + actions: [ + { + id: "check", + title: "Check for PI WEB Updates", + description: "Bypass cached release data and check the selected machine now", + group: "Updates", + enabled: (context) => context.checkForPiWebUpdates !== undefined, + disabledReason: () => "Update checks require a newer PI WEB gateway", + run: (context) => context.checkForPiWebUpdates?.(), + }, + ], workspacePanels: [ { id: "workspace.updates", diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 8f679d4..c3b5bf3 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -13,6 +13,20 @@ const workspace: Workspace = { isGitWorktree: true, }; +function piWebStatusResponse() { + return { + packageName: "@jmfederico/pi-web", + generatedAt: "now", + components: { + web: { component: "web", label: "PI WEB", available: true, stale: false }, + sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false }, + }, + release: { packageName: "@jmfederico/pi-web", updateAvailable: false }, + commands: {}, + messages: [], + }; +} + const commandRun: TerminalCommandRun = { id: "run1", origin: "core", @@ -32,17 +46,7 @@ afterEach(() => { describe("machine-scoped runtime API", () => { it("reads machine PI WEB status through the gateway route", async () => { - const fetchMock = stubJsonFetch({ - packageName: "@jmfederico/pi-web", - generatedAt: "now", - components: { - web: { component: "web", label: "PI WEB", available: true, stale: false }, - sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false }, - }, - release: { packageName: "@jmfederico/pi-web", updateAvailable: false }, - commands: {}, - messages: [], - }); + const fetchMock = stubJsonFetch(piWebStatusResponse()); await piWebApi.piWebStatus("remote a"); @@ -50,6 +54,26 @@ describe("machine-scoped runtime API", () => { expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status"); }); + it("requests an uncached update check through the local status route", async () => { + const fetchMock = stubJsonFetch(piWebStatusResponse()); + + await piWebApi.checkForUpdates(); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchCall(fetchMock, 0)[0]).toBe("/api/pi-web/status?refresh=1"); + expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store"); + }); + + it("requests an uncached update check through the selected machine route", async () => { + const fetchMock = stubJsonFetch(piWebStatusResponse()); + + await piWebApi.checkForUpdates("remote a"); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status?refresh=1"); + expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store"); + }); + it("reads machine runtime through the gateway route", async () => { const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }); diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 61a6567..c0cfefa 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -99,8 +99,13 @@ function sessionBulkMutationRef(session: SessionLookup): SessionBulkMutationRef return cwd === undefined || cwd === "" ? { id } : { id, cwd }; } +function piWebStatusUrl(machineId: string): string { + return machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`; +} + export const piWebApi = { - piWebStatus: (machineId = "local") => request(machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`, parsePiWebStatusResponse), + piWebStatus: (machineId = "local") => request(piWebStatusUrl(machineId), parsePiWebStatusResponse), + checkForUpdates: (machineId = "local") => request(`${piWebStatusUrl(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }), piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse), }; diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index ad1fd65..a851f59 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -28,6 +28,7 @@ describe("federated route contract", () => { await Promise.all([ ignoreParseFailure(piWebApi.piWebStatus(machineId)), + ignoreParseFailure(piWebApi.checkForUpdates(machineId)), ignoreParseFailure(configApi.config(machineId)), ignoreParseFailure(configApi.saveConfig({ spawnSessions: true }, machineId)), ignoreParseFailure(pluginsApi.plugins(machineId)), diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 6e8a8d5..fee70a6 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1,6 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; -import { configApi, effectiveWorkspaceUploadFolder, piWebApi, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api"; +import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api"; import type { AppAction } from "../actions"; import { initialAppState, type AppState } from "../appState"; import { isSessionActive } from "../../../shared/activity"; @@ -11,6 +11,7 @@ import { FileExplorerController } from "../controllers/fileExplorerController"; import { GitController } from "../controllers/gitController"; import { MachineController } from "../controllers/machineController"; import { ProjectController } from "../controllers/projectController"; +import { PiWebStatusController } from "../controllers/piWebStatusController"; import { SessionController } from "../controllers/sessionController"; import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController"; import { emptyMachineNavigationSnapshot, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, SessionStorageMachineNavigationMemory, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory"; @@ -132,6 +133,11 @@ export class PiWebApp extends LitElement { () => { this.updateUrl(); }, this.projects, ); + private readonly piWebStatusController = new PiWebStatusController( + () => this.state, + (patch) => { this.setState(patch); }, + { onRefreshError: (machineId, error) => { console.warn(`Failed to refresh PI WEB status for ${machineId}`, error); } }, + ); private readonly files = new FileExplorerController( () => this.state, (patch) => { this.setState(patch); }, @@ -298,7 +304,7 @@ export class PiWebApp extends LitElement { this.clearScheduledPiWebStatusRefresh(); this.piWebStatusDeferredTimer = window.setTimeout(() => { this.piWebStatusDeferredTimer = undefined; - void this.refreshPiWebStatus(); + void this.piWebStatusController.refresh(); }, delayMs); } @@ -308,17 +314,6 @@ export class PiWebApp extends LitElement { this.piWebStatusDeferredTimer = undefined; } - private async refreshPiWebStatus(): Promise { - const machineId = selectedMachineId(this.state); - try { - const piWebStatus = await piWebApi.piWebStatus(machineId); - if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus }); - } catch (error) { - if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus: undefined }); - console.warn(`Failed to refresh PI WEB status for ${machineId}`, error); - } - } - private async refreshWorkspaceActivity(machineId = selectedMachineId(this.state)): Promise { try { await this.activity.refresh(machineId); @@ -1573,6 +1568,7 @@ export class PiWebApp extends LitElement { refreshFiles: () => this.files.refreshFiles(), refreshGit: () => this.git.refreshGit(), refreshAppData: () => this.refreshAppData(), + checkForPiWebUpdates: () => this.piWebStatusController.checkForUpdates(), reloadPage: () => { this.hardReloadApp(); }, deleteWorkspace: (workspace) => this.deleteWorkspace(workspace), startSession: () => this.withChatScrollTransition(() => this.startSessionAndOpenChat()), diff --git a/src/client/src/controllers/piWebStatusController.test.ts b/src/client/src/controllers/piWebStatusController.test.ts new file mode 100644 index 0000000..1020fc7 --- /dev/null +++ b/src/client/src/controllers/piWebStatusController.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Machine, PiWebReleaseStatus, PiWebStatusResponse } from "../api"; +import { initialAppState, type AppState } from "../appState"; +import { PiWebStatusController, type PiWebStatusControllerDependencies } from "./piWebStatusController"; + +type StatusApi = NonNullable; + +describe("PiWebStatusController", () => { + it("targets the selected machine and applies refreshed status", async () => { + const harness = createHarness("remote-a"); + harness.piWebStatus.mockResolvedValue(status("remote")); + + await harness.controller.refresh(); + + expect(harness.piWebStatus).toHaveBeenCalledWith("remote-a"); + expect(harness.state().piWebStatus?.generatedAt).toBe("remote"); + }); + + it("does not let an older periodic response overwrite a forced response", async () => { + const harness = createHarness(); + const regular = createDeferred(); + const forced = createDeferred(); + harness.piWebStatus.mockReturnValue(regular.promise); + harness.checkForUpdates.mockReturnValue(forced.promise); + + const regularRequest = harness.controller.refresh(); + const forcedRequest = harness.controller.checkForUpdates(); + forced.resolve(status("forced")); + await forcedRequest; + regular.resolve(status("regular")); + await regularRequest; + + expect(harness.state().piWebStatus?.generatedAt).toBe("forced"); + }); + + it("deduplicates forced checks and suppresses periodic refresh while one is pending", async () => { + const harness = createHarness(); + const forced = createDeferred(); + harness.checkForUpdates.mockReturnValue(forced.promise); + + const first = harness.controller.checkForUpdates(); + const second = harness.controller.checkForUpdates(); + await harness.controller.refresh(); + + expect(second).toBe(first); + expect(harness.checkForUpdates).toHaveBeenCalledOnce(); + expect(harness.piWebStatus).not.toHaveBeenCalled(); + + forced.resolve(status("forced")); + await first; + }); + + it("does not apply a response or error after the selected machine changes", async () => { + const harness = createHarness("remote-a"); + const forced = createDeferred(); + harness.checkForUpdates.mockReturnValue(forced.promise); + + const request = harness.controller.checkForUpdates(); + harness.selectMachine("remote-b"); + forced.resolve(status("remote-a", { error: "registry unavailable" })); + await expect(request).resolves.toBeUndefined(); + + expect(harness.state().piWebStatus).toBeUndefined(); + }); + + it.each([ + [{ error: "registry unavailable" }, "PI WEB update check failed: registry unavailable"], + [{ skipped: true }, "PI WEB update check was skipped"], + ] as const)("applies status and rejects an unsuccessful manual check", async (release, message) => { + const harness = createHarness(); + harness.checkForUpdates.mockResolvedValue(status("checked", release)); + + await expect(harness.controller.checkForUpdates()).rejects.toThrow(message); + + expect(harness.state().piWebStatus?.generatedAt).toBe("checked"); + }); + + it("clears current status and reports periodic refresh failures", async () => { + const harness = createHarness(); + const error = new Error("offline"); + harness.setStatus(status("old")); + harness.piWebStatus.mockRejectedValue(error); + + await harness.controller.refresh(); + + expect(harness.state().piWebStatus).toBeUndefined(); + expect(harness.onRefreshError).toHaveBeenCalledWith("local", error); + }); +}); + +function createHarness(machineId = "local") { + let state: AppState = { ...initialAppState(), selectedMachine: machine(machineId) }; + const piWebStatus = vi.fn(); + const checkForUpdates = vi.fn(); + const onRefreshError = vi.fn<(machineId: string, error: unknown) => void>(); + const controller = new PiWebStatusController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + { api: { piWebStatus, checkForUpdates }, onRefreshError }, + ); + return { + controller, + piWebStatus, + checkForUpdates, + onRefreshError, + state: () => state, + setStatus: (piWebStatusValue: PiWebStatusResponse) => { state = { ...state, piWebStatus: piWebStatusValue }; }, + selectMachine: (id: string) => { state = { ...state, selectedMachine: machine(id) }; }, + }; +} + +function machine(id: string): Machine { + return { + id, + name: id, + kind: id === "local" ? "local" : "remote", + ...(id === "local" ? {} : { baseUrl: `https://${id}.example.test` }), + createdAt: "now", + updatedAt: "now", + }; +} + +function status(generatedAt: string, release: Partial = {}): PiWebStatusResponse { + return { + packageName: "@jmfederico/pi-web", + generatedAt, + components: { + web: { component: "web", label: "Web/UI", stale: false, available: true }, + sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true }, + }, + release: { packageName: "@jmfederico/pi-web", updateAvailable: false, ...release }, + commands: {}, + messages: [], + }; +} + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} diff --git a/src/client/src/controllers/piWebStatusController.ts b/src/client/src/controllers/piWebStatusController.ts new file mode 100644 index 0000000..3254aab --- /dev/null +++ b/src/client/src/controllers/piWebStatusController.ts @@ -0,0 +1,68 @@ +import { piWebApi, type PiWebStatusResponse } from "../api"; +import { selectedMachineId, type GetState, type SetState } from "./types"; + +export interface PiWebStatusControllerDependencies { + api?: Pick; + onRefreshError?: (machineId: string, error: unknown) => void; +} + +export class PiWebStatusController { + private readonly api: Pick; + private readonly onRefreshError: (machineId: string, error: unknown) => void; + private requestSequence = 0; + private pendingUpdateCheck: { machineId: string; requestSequence: number; promise: Promise } | undefined; + + constructor( + private readonly getState: GetState, + private readonly setState: SetState, + dependencies: PiWebStatusControllerDependencies = {}, + ) { + this.api = dependencies.api ?? piWebApi; + this.onRefreshError = dependencies.onRefreshError ?? (() => undefined); + } + + async refresh(): Promise { + const machineId = selectedMachineId(this.getState()); + if (this.pendingUpdateCheck?.machineId === machineId) return; + const requestSequence = ++this.requestSequence; + try { + const piWebStatus = await this.api.piWebStatus(machineId); + if (this.isCurrent(machineId, requestSequence)) this.setState({ piWebStatus }); + } catch (error) { + if (!this.isCurrent(machineId, requestSequence)) return; + this.setState({ piWebStatus: undefined }); + this.onRefreshError(machineId, error); + } + } + + checkForUpdates(): Promise { + const machineId = selectedMachineId(this.getState()); + const existing = this.pendingUpdateCheck; + if (existing?.machineId === machineId) return existing.promise; + + const requestSequence = ++this.requestSequence; + const promise = this.api.checkForUpdates(machineId) + .then((piWebStatus) => { + if (!this.isCurrent(machineId, requestSequence)) return; + this.setState({ piWebStatus }); + throwForUnsuccessfulReleaseCheck(piWebStatus); + }) + .catch((error: unknown) => { + if (this.isCurrent(machineId, requestSequence)) throw error; + }) + .finally(() => { + if (this.pendingUpdateCheck?.requestSequence === requestSequence) this.pendingUpdateCheck = undefined; + }); + this.pendingUpdateCheck = { machineId, requestSequence, promise }; + return promise; + } + + private isCurrent(machineId: string, requestSequence: number): boolean { + return selectedMachineId(this.getState()) === machineId && requestSequence === this.requestSequence; + } +} + +function throwForUnsuccessfulReleaseCheck(status: PiWebStatusResponse): void { + if (status.release.error !== undefined) throw new Error(`PI WEB update check failed: ${status.release.error}`); + if (status.release.skipped === true) throw new Error("PI WEB update check was skipped because remote version checks are disabled by offline/version-check settings"); +} diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index fd5cb8e..a0cc1ee 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -112,6 +112,7 @@ export interface PluginRuntimeContext { refreshFiles: () => void | Promise; refreshGit: () => void | Promise; refreshAppData: () => void | Promise; + checkForPiWebUpdates?: () => void | Promise; reloadPage: () => void; deleteWorkspace: (workspace?: Workspace) => void | Promise; startSession: () => void | Promise; diff --git a/src/plugin-api.ts b/src/plugin-api.ts index 13fcab4..6b43850 100644 --- a/src/plugin-api.ts +++ b/src/plugin-api.ts @@ -99,6 +99,8 @@ export interface PluginRuntimeContext { refreshFiles: () => void | Promise; refreshGit: () => void | Promise; refreshAppData: () => void | Promise; + /** Force a fresh PI WEB release check on the selected machine. Optional for compatibility with older hosts. */ + checkForPiWebUpdates?: () => void | Promise; reloadPage: () => void; startSession: () => void | Promise; archiveSession: () => void | Promise; diff --git a/src/server/app.piWebStatus.test.ts b/src/server/app.piWebStatus.test.ts new file mode 100644 index 0000000..20442ae --- /dev/null +++ b/src/server/app.piWebStatus.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PiWebStatusResponse } from "../shared/apiTypes.js"; +import { buildApp } from "./app.js"; + +describe("PI WEB status routes", () => { + it("forces a fresh status load when refresh is requested", async () => { + const get = vi.fn(() => Promise.resolve(status("cached"))); + const refresh = vi.fn(() => Promise.resolve(status("forced"))); + const app = await buildApp({ piWebStatusCache: { get, refresh }, clientDist: false, logger: false }); + + try { + const cachedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status" }); + const forcedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status?refresh=1" }); + + expect(cachedResponse.json().generatedAt).toBe("cached"); + expect(forcedResponse.json().generatedAt).toBe("forced"); + expect(get).toHaveBeenCalledOnce(); + expect(refresh).toHaveBeenCalledOnce(); + expect(refresh).toHaveBeenCalledWith({ force: true }); + } finally { + await app.close(); + } + }); +}); + +function status(generatedAt: string): PiWebStatusResponse { + return { + packageName: "@jmfederico/pi-web", + generatedAt, + components: { + web: { component: "web", label: "Web/UI", stale: false, available: true }, + sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true }, + }, + release: { packageName: "@jmfederico/pi-web", updateAvailable: false }, + commands: {}, + messages: [], + }; +} diff --git a/src/server/app.remoteProxy.test.ts b/src/server/app.remoteProxy.test.ts index 22ef140..e946c24 100644 --- a/src/server/app.remoteProxy.test.ts +++ b/src/server/app.remoteProxy.test.ts @@ -25,6 +25,23 @@ describe("buildApp remote machine proxy routes", () => { expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined); }); + it("preserves the force-refresh query when proxying update checks", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const request = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Readable.from([JSON.stringify({ ok: true })]), + })); + appTestContext.remoteClient = fakeRemoteClient({ request }); + + const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web/status?refresh=1` }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ ok: true }); + expect(request).toHaveBeenCalledWith("GET", "/api/pi-web/status?refresh=1", undefined); + }); + it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => { const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const remote = addResponse.json<{ id: string }>(); diff --git a/src/server/app.ts b/src/server/app.ts index 8d28f45..38d32b7 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -23,7 +23,7 @@ import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachin import { PiWebPluginService } from "./piWebPluginService.js"; import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js"; import { registerPiPackageRoutes } from "./piPackageRoutes.js"; -import { createPiWebStatusCache } from "./piWebStatusCache.js"; +import { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.js"; import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; import { MachineService } from "./machines/machineService.js"; import { registerMachineRoutes } from "./machines/machineRoutes.js"; @@ -38,6 +38,7 @@ export interface AppDependencies { sessionDaemon?: SessionProxyDaemon; piWebPlugins?: Pick; piPackages?: PiPackageService; + piWebStatusCache?: PiWebStatusCache; config?: PiWebConfigService; clientDist?: string | false; logger?: FastifyServerOptions["logger"]; @@ -136,9 +137,10 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebStatus(sessionDaemon), { - onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); }, - }); + const piWebStatusCache = deps.piWebStatusCache ?? createPiWebStatusCache( + ({ force }) => getPiWebStatus(sessionDaemon, { forceReleaseCheck: force }), + { onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); } }, + ); const machines = deps.machines ?? new MachineService(undefined, { localRuntime: () => getPiWebRuntime(sessionDaemon), }); @@ -153,7 +155,9 @@ export async function buildApp(deps: AppDependencies = {}): Promise piWebStatusCache.get()); + app.get<{ Querystring: { refresh?: string } }>("/api/pi-web/status", async (request) => request.query.refresh === "1" + ? piWebStatusCache.refresh({ force: true }) + : piWebStatusCache.get()); app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon)); app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon)); app.get("/api/plugins", async () => piWebPlugins.plugins()); diff --git a/src/server/piWebReleaseLookupCache.test.ts b/src/server/piWebReleaseLookupCache.test.ts new file mode 100644 index 0000000..5607ddb --- /dev/null +++ b/src/server/piWebReleaseLookupCache.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; +import { createPiWebReleaseLookupCache } from "./piWebReleaseLookupCache.js"; + +describe("createPiWebReleaseLookupCache", () => { + it("serves a fresh cached release lookup", async () => { + let now = 1_000; + const load = vi.fn(() => Promise.resolve("1.0.0")); + const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now }); + + await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 }); + now = 1_050; + await expect(cache.get("0.9.1")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 }); + + expect(load).toHaveBeenCalledOnce(); + expect(load).toHaveBeenCalledWith("0.9.0"); + }); + + it("bypasses a fresh lookup when forced", async () => { + let now = 1_000; + const load = vi.fn() + .mockResolvedValueOnce("1.0.0") + .mockResolvedValueOnce("1.1.0"); + const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now }); + + await cache.get("0.9.0"); + now = 1_050; + + await expect(cache.get("0.9.0", { force: true })).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 }); + await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 }); + expect(load).toHaveBeenCalledTimes(2); + }); + + it.each(["forced-first", "regular-first"] as const)("does not let an older regular lookup replace a forced result when %s completes", async (completionOrder) => { + const regular = createDeferred(); + const forced = createDeferred(); + const load = vi.fn() + .mockImplementationOnce(() => regular.promise) + .mockImplementationOnce(() => forced.promise); + const cache = createPiWebReleaseLookupCache(load); + + const regularLookup = cache.get("0.9.0"); + const forcedLookup = cache.get("0.9.0", { force: true }); + if (completionOrder === "forced-first") { + forced.resolve("2.0.0"); + await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" }); + regular.resolve("1.0.0"); + await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" }); + } else { + regular.resolve("1.0.0"); + await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" }); + forced.resolve("2.0.0"); + await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" }); + } + + await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "2.0.0" }); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("makes regular callers join a pending forced lookup", async () => { + const forced = createDeferred(); + const load = vi.fn(() => forced.promise); + const cache = createPiWebReleaseLookupCache(load); + + const forcedLookup = cache.get("0.9.0", { force: true }); + const regularLookup = cache.get("0.9.0"); + + expect(regularLookup).toBe(forcedLookup); + forced.resolve("2.0.0"); + await expect(regularLookup).resolves.toMatchObject({ latestVersion: "2.0.0" }); + expect(load).toHaveBeenCalledOnce(); + }); +}); + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} diff --git a/src/server/piWebReleaseLookupCache.ts b/src/server/piWebReleaseLookupCache.ts new file mode 100644 index 0000000..35bd2b3 --- /dev/null +++ b/src/server/piWebReleaseLookupCache.ts @@ -0,0 +1,57 @@ +const DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS = 6 * 60 * 60 * 1000; + +export interface PiWebReleaseLookup { + checkedAtMs: number; + latestVersion?: string; + error?: string; +} + +export interface PiWebReleaseLookupCacheOptions { + ttlMs?: number; + now?: () => number; +} + +export interface PiWebReleaseLookupOptions { + force?: boolean; +} + +export interface PiWebReleaseLookupCache { + get(currentVersion: string, options?: PiWebReleaseLookupOptions): Promise; +} + +export function createPiWebReleaseLookupCache( + load: (currentVersion: string) => Promise, + options: PiWebReleaseLookupCacheOptions = {}, +): PiWebReleaseLookupCache { + const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS; + const now = options.now ?? Date.now; + let cached: PiWebReleaseLookup | undefined; + let pending: { promise: Promise; force: boolean; sequence: number } | undefined; + let loadSequence = 0; + + return { + get(currentVersion: string, lookupOptions: PiWebReleaseLookupOptions = {}): Promise { + const force = lookupOptions.force === true; + if (pending?.force === true) return pending.promise; + + const checkedAtMs = now(); + if (!force && cached !== undefined && checkedAtMs - cached.checkedAtMs < ttlMs) return Promise.resolve(cached); + if (!force && pending !== undefined) return pending.promise; + + const sequence = ++loadSequence; + const promise = Promise.resolve() + .then(() => load(currentVersion)) + .then((latestVersion): PiWebReleaseLookup => ({ checkedAtMs, latestVersion })) + .catch((error: unknown): PiWebReleaseLookup => ({ checkedAtMs, error: error instanceof Error ? error.message : String(error) })) + .then((lookup) => { + if (sequence === loadSequence) cached = lookup; + return lookup; + }) + .finally(() => { + if (pending?.sequence === sequence) pending = undefined; + }); + pending = { promise, force, sequence }; + return promise; + }, + }; +} diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index 15cd1a5..77e0ab7 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -69,6 +69,33 @@ describe("PI WEB status", () => { expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings])); }); + it("bypasses cached npm release data for a forced check", async () => { + Reflect.deleteProperty(process.env, "PI_WEB_SKIP_VERSION_CHECK"); + process.env["PI_WEB_DOCKER_RUNTIME"] = "1"; + process.env["PI_WEB_DOCKER_MODE"] = "runtime"; + const fetchMock = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(npmVersionResponse("1.202607.1")) + .mockResolvedValueOnce(npmVersionResponse("1.202607.2")); + const daemon = daemonWithComponent({ + component: "sessiond", + label: "Session daemon", + runtimeVersion: "1.202607.0", + installedVersion: "1.202607.0", + stale: false, + available: true, + installation: { kind: "docker", dockerMode: "runtime" }, + }); + + const first = await getPiWebStatus(daemon, { forceReleaseCheck: true }); + const cached = await getPiWebStatus(daemon); + const forced = await getPiWebStatus(daemon, { forceReleaseCheck: true }); + + expect(first.release.latestVersion).toBe("1.202607.1"); + expect(cached.release.latestVersion).toBe("1.202607.1"); + expect(forced.release.latestVersion).toBe("1.202607.2"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it("reports stale session daemon versions as messages", async () => { process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1"; disableDockerRuntimeEnv(); @@ -82,7 +109,7 @@ describe("PI WEB status", () => { installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, }); - const status = await getPiWebStatus(daemon); + const status = await getPiWebStatus(daemon, { forceReleaseCheck: true }); expect(status.release.skipped).toBe(true); expect(status.components.sessiond.stale).toBe(true); @@ -192,6 +219,10 @@ describe("PI WEB status", () => { }); }); +function npmVersionResponse(version: string): Response { + return new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } }); +} + function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClient { const daemon = new SessionDaemonClient(); vi.spyOn(daemon, "request").mockResolvedValue({ diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts index 7385783..b616887 100644 --- a/src/server/piWebStatus.ts +++ b/src/server/piWebStatus.ts @@ -11,11 +11,11 @@ import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/ import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js"; import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; +import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js"; const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`; const DEFAULT_VERSION = "0.0.0-dev"; -const LATEST_RELEASE_CACHE_MS = 6 * 60 * 60 * 1000; const VERSION_CHECK_TIMEOUT_MS = 5000; type ServiceId = "sessiond" | "web" | "uiDev"; @@ -74,8 +74,11 @@ interface PiWebStatusDaemon { request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }>; } -let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined; +export interface PiWebStatusOptions { + forceReleaseCheck?: boolean; +} +const latestReleaseLookupCache = createPiWebReleaseLookupCache(fetchLatestNpmVersion); const runtimePackageInfo = readPackageInfoSync(); export function getPiWebRuntimeComponent(component: PiWebServiceComponent, capabilities: readonly PiWebCapability[] = []): PiWebRuntimeComponent { @@ -129,10 +132,10 @@ export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new Sess }; } -export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise { +export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise { const versionStatus = await getPiWebVersionStatus(daemon); const { web, sessiond } = versionStatus.components; - const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION); + const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true); const components = { web, sessiond }; const commands = await commandsFor(components); const messages = buildMessages(components, release, commands); @@ -375,25 +378,16 @@ function unavailableSessiond(error: string): PiWebComponentStatus { }; } -async function getLatestReleaseStatus(currentVersion: string): Promise { +async function getLatestReleaseStatus(currentVersion: string, force: boolean): Promise { const checkedAtMs = Date.now(); if (skipVersionCheck()) { return { packageName: PI_WEB_PACKAGE_NAME, updateAvailable: false, checkedAt: new Date(checkedAtMs).toISOString(), skipped: true }; } - if (latestReleaseCache !== undefined && checkedAtMs - latestReleaseCache.checkedAtMs < LATEST_RELEASE_CACHE_MS) { - return releaseStatusFromCache(latestReleaseCache, currentVersion); - } - - try { - latestReleaseCache = { checkedAtMs, latestVersion: await fetchLatestNpmVersion(currentVersion) }; - } catch (error) { - latestReleaseCache = { checkedAtMs, error: error instanceof Error ? error.message : String(error) }; - } - return releaseStatusFromCache(latestReleaseCache, currentVersion); + return releaseStatusFromCache(await latestReleaseLookupCache.get(currentVersion, { force }), currentVersion); } -function releaseStatusFromCache(cache: { checkedAtMs: number; latestVersion?: string; error?: string }, currentVersion: string): PiWebReleaseStatus { +function releaseStatusFromCache(cache: PiWebReleaseLookup, currentVersion: string): PiWebReleaseStatus { return { packageName: PI_WEB_PACKAGE_NAME, ...(cache.latestVersion === undefined ? {} : { latestVersion: cache.latestVersion }), diff --git a/src/server/piWebStatusCache.test.ts b/src/server/piWebStatusCache.test.ts index 0d94142..25ccee4 100644 --- a/src/server/piWebStatusCache.test.ts +++ b/src/server/piWebStatusCache.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { PiWebStatusResponse } from "../shared/apiTypes.js"; -import { createPiWebStatusCache } from "./piWebStatusCache.js"; +import { createPiWebStatusCache, type PiWebStatusCacheLoadOptions } from "./piWebStatusCache.js"; describe("createPiWebStatusCache", () => { it("serves cached status while it is fresh", async () => { @@ -46,6 +46,45 @@ describe("createPiWebStatusCache", () => { expect(load).toHaveBeenCalledTimes(2); }); + it.each(["forced-first", "regular-first"] as const)("does not let an older refresh replace a forced result when %s completes", async (completionOrder) => { + const regular = createDeferred(); + const forced = createDeferred(); + const load = vi.fn(({ force }: PiWebStatusCacheLoadOptions) => force ? forced.promise : regular.promise); + const cache = createPiWebStatusCache(load); + + const regularRefresh = cache.refresh(); + const forcedRefresh = cache.refresh({ force: true }); + if (completionOrder === "forced-first") { + forced.resolve(status("forced")); + await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" }); + regular.resolve(status("regular")); + await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" }); + } else { + regular.resolve(status("regular")); + await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" }); + forced.resolve(status("forced")); + await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" }); + } + + await expect(cache.get()).resolves.toMatchObject({ generatedAt: "forced" }); + expect(load).toHaveBeenNthCalledWith(1, { force: false }); + expect(load).toHaveBeenNthCalledWith(2, { force: true }); + }); + + it("makes regular refreshes join a pending forced refresh", async () => { + const deferred = createDeferred(); + const load = vi.fn(() => deferred.promise); + const cache = createPiWebStatusCache(load); + + const forced = cache.refresh({ force: true }); + const regular = cache.refresh(); + + expect(regular).toBe(forced); + deferred.resolve(status("forced")); + await forced; + expect(load).toHaveBeenCalledOnce(); + }); + it("retains stale status and reports background refresh errors", async () => { let now = 1_000; const refreshError = new Error("refresh failed"); diff --git a/src/server/piWebStatusCache.ts b/src/server/piWebStatusCache.ts index ec8813b..d8b8860 100644 --- a/src/server/piWebStatusCache.ts +++ b/src/server/piWebStatusCache.ts @@ -8,28 +8,42 @@ export interface PiWebStatusCacheOptions { onError?: (error: unknown) => void; } -export interface PiWebStatusCache { - get(): Promise; - refresh(): Promise; +export interface PiWebStatusCacheLoadOptions { + force: boolean; } -export function createPiWebStatusCache(load: () => Promise, options: PiWebStatusCacheOptions = {}): PiWebStatusCache { +export interface PiWebStatusCacheRefreshOptions { + force?: boolean; +} + +export interface PiWebStatusCache { + get(): Promise; + refresh(options?: PiWebStatusCacheRefreshOptions): Promise; +} + +export function createPiWebStatusCache(load: (options: PiWebStatusCacheLoadOptions) => Promise, options: PiWebStatusCacheOptions = {}): PiWebStatusCache { const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS; const now = options.now ?? Date.now; let cached: { status: PiWebStatusResponse; expiresAt: number } | undefined; - let pending: Promise | undefined; + let pending: { promise: Promise; force: boolean; sequence: number } | undefined; + let loadSequence = 0; - const refresh = (): Promise => { - pending ??= Promise.resolve() - .then(load) + const refresh = (refreshOptions: PiWebStatusCacheRefreshOptions = {}): Promise => { + const force = refreshOptions.force === true; + if (pending !== undefined && (!force || pending.force)) return pending.promise; + + const sequence = ++loadSequence; + const promise = Promise.resolve() + .then(() => load({ force })) .then((status) => { - cached = { status, expiresAt: now() + ttlMs }; + if (sequence === loadSequence) cached = { status, expiresAt: now() + ttlMs }; return status; }) .finally(() => { - pending = undefined; + if (pending?.sequence === sequence) pending = undefined; }); - return pending; + pending = { promise, force, sequence }; + return promise; }; return { From 27af5a70ad5f6a0f4e4a7280d3f66337123d1b5c Mon Sep 17 00:00:00 2001 From: chenyimeng Date: Sun, 12 Jul 2026 03:57:23 +0000 Subject: [PATCH 093/111] feat: support deploying pi-web under an arbitrary base path Make all browser-facing API paths and WebSocket URLs relative so that vite's can resolve them correctly when pi-web is served from a subpath (e.g. /ai or /test/ai). Also introduce PI_WEB_BASE_PATH so the server can generate plugin module URLs that include the base path. --- src/client/src/api/clients.test.ts | 72 +++++++++---------- src/client/src/api/clients.ts | 22 +++--- .../src/api/federatedRouteContract.test.ts | 2 +- src/client/src/api/sockets.test.ts | 10 +-- src/client/src/api/sockets.ts | 5 +- src/client/src/api/urls.ts | 8 +-- src/client/src/api/workspaceUploads.test.ts | 8 +-- src/client/src/components/PiWebApp.ts | 2 +- src/client/src/plugins/external.ts | 2 +- .../machines/machinePluginProxyRoutes.ts | 7 +- src/server/piWebPluginService.ts | 7 +- 11 files changed, 77 insertions(+), 68 deletions(-) diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index c3b5bf3..02c972c 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -51,7 +51,7 @@ describe("machine-scoped runtime API", () => { await piWebApi.piWebStatus("remote a"); expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status"); + expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/pi-web/status"); }); it("requests an uncached update check through the local status route", async () => { @@ -80,7 +80,7 @@ describe("machine-scoped runtime API", () => { await machinesApi.runtime("remote a"); expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/runtime"); + expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/runtime"); }); }); @@ -97,9 +97,9 @@ describe("settings config and plugin APIs", () => { await expect(pluginsApi.plugins()).resolves.toEqual(piWebPluginsResponse()); expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ - "/api/config", - "/api/config", - "/api/plugins", + "api/config", + "api/config", + "api/plugins", ]); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } }); @@ -117,9 +117,9 @@ describe("settings config and plugin APIs", () => { await expect(pluginsApi.plugins("remote a")).resolves.toEqual(piWebPluginsResponse()); expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ - "/api/machines/remote%20a/config", - "/api/machines/remote%20a/config", - "/api/machines/remote%20a/plugins", + "api/machines/remote%20a/config", + "api/machines/remote%20a/config", + "api/machines/remote%20a/plugins", ]); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } }); @@ -144,11 +144,11 @@ describe("Pi package API", () => { await piPackagesApi.update(); expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ - "/api/pi-packages", - "/api/pi-packages/install", - "/api/pi-packages/remove", - "/api/pi-packages/update", - "/api/pi-packages/update", + "api/pi-packages", + "api/pi-packages/install", + "api/pi-packages/remove", + "api/pi-packages/update", + "api/pi-packages/update", ]); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ source: "npm:@acme/new-tools" }); @@ -174,11 +174,11 @@ describe("Pi package API", () => { await piPackagesApi.update(undefined, "remote a"); expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ - "/api/machines/local/pi-packages", - "/api/machines/remote%20a/pi-packages", - "/api/machines/remote%20a/pi-packages/install", - "/api/machines/remote%20a/pi-packages/remove", - "/api/machines/remote%20a/pi-packages/update", + "api/machines/local/pi-packages", + "api/machines/remote%20a/pi-packages", + "api/machines/remote%20a/pi-packages/install", + "api/machines/remote%20a/pi-packages/remove", + "api/machines/remote%20a/pi-packages/update", ]); expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ source: "npm:@acme/new-tools" }); expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "../project-tools" }); @@ -196,10 +196,10 @@ describe("session API compatibility", () => { await expect(sessionsApi.cleanup({ archiveIdleDays: 7, projectCwds: ["/repo"] }, "remote a")).resolves.toEqual(executed); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/sessions/cleanup/preview"); + expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/sessions/cleanup/preview"); expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ archiveIdleDays: 7, deleteArchivedDays: null }); - expect(fetchCall(fetchMock, 1)[0]).toBe("/api/machines/remote%20a/sessions/cleanup"); + expect(fetchCall(fetchMock, 1)[0]).toBe("api/machines/remote%20a/sessions/cleanup"); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ archiveIdleDays: 7, projectCwds: ["/repo"] }); }); @@ -213,10 +213,10 @@ describe("session API compatibility", () => { await expect(sessionsApi.deleteArchivedMany([{ id: "s 1", cwd: "/repo" }], "remote a")).resolves.toEqual(deleted); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/sessions/bulk/archive"); + expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/sessions/bulk/archive"); expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }, { id: "s 2" }] }); - expect(fetchCall(fetchMock, 1)[0]).toBe("/api/machines/remote%20a/sessions/bulk/delete-archived"); + expect(fetchCall(fetchMock, 1)[0]).toBe("api/machines/remote%20a/sessions/bulk/delete-archived"); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] }); }); @@ -228,7 +228,7 @@ describe("session API compatibility", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchCall(fetchMock, 0); - expect(url).toBe("/api/machines/remote%20a/sessions/s%201/prompt"); + expect(url).toBe("api/machines/remote%20a/sessions/s%201/prompt"); expect(JSON.parse(requestBody(init))).toEqual({ text: "hello", streamingBehavior: "followUp" }); }); @@ -239,7 +239,7 @@ describe("session API compatibility", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchCall(fetchMock, 0); - expect(url).toBe("/api/machines/remote%20a/sessions/s%201/prompt"); + expect(url).toBe("api/machines/remote%20a/sessions/s%201/prompt"); expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo", text: "hello" }); }); }); @@ -251,7 +251,7 @@ describe("machine-scoped file suggestion API", () => { await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a", workspaceScoped: true }); expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked"); + expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked"); }); it("falls back to the legacy cwd route when workspace-scoped suggestions are not enabled", async () => { @@ -260,7 +260,7 @@ describe("machine-scoped file suggestion API", () => { await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a" }); expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo"); + expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo"); }); }); @@ -272,7 +272,7 @@ describe("machine-scoped terminal command-run API", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchCall(fetchMock, 0); - expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1"); + expect(url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1"); expect(init?.method).toBe("DELETE"); }); @@ -283,7 +283,7 @@ describe("machine-scoped terminal command-run API", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchCall(fetchMock, 0); - expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminal-command-runs"); + expect(url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminal-command-runs"); expect(init?.method).toBe("POST"); expect(JSON.parse(requestBody(init))).toEqual({ origin: "core", title: "Build", command: "npm test", metadata: {} }); }); @@ -295,7 +295,7 @@ describe("machine-scoped terminal command-run API", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchCall(fetchMock, 0); - expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminals"); + expect(url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminals"); expect(init?.method).toBe("DELETE"); }); @@ -311,9 +311,9 @@ describe("machine-scoped terminal command-run API", () => { await terminalsApi.cancelCommandRun("run 1", "remote a"); expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ - "/api/machines/remote%20a/terminal-command-runs?projectId=p+1&workspaceId=w%2F1&statuses=running&metadata=%7B%22pi.operation%22%3A%22workspace.delete%22%7D", - "/api/machines/remote%20a/terminal-command-runs/run%201", - "/api/machines/remote%20a/terminal-command-runs/run%201/cancel", + "api/machines/remote%20a/terminal-command-runs?projectId=p+1&workspaceId=w%2F1&statuses=running&metadata=%7B%22pi.operation%22%3A%22workspace.delete%22%7D", + "api/machines/remote%20a/terminal-command-runs/run%201", + "api/machines/remote%20a/terminal-command-runs/run%201/cancel", ]); expect(fetchCall(fetchMock, 2)[1]?.method).toBe("POST"); }); @@ -323,7 +323,7 @@ describe("machine-scoped terminal command-run API", () => { await expect(terminalsApi.getCommandRun("missing", "remote-a")).resolves.toBeUndefined(); - expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote-a/terminal-command-runs/missing"); + expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote-a/terminal-command-runs/missing"); }); }); @@ -335,7 +335,7 @@ describe("workspace file write API", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchCall(fetchMock, 0); - expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt"); + expect(url).toBe("api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt"); expect(init?.method).toBe("PUT"); expect(new Headers(init?.headers).get("content-type")).toBe("text/plain"); }); @@ -348,7 +348,7 @@ describe("workspace file write API", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchCall(fetchMock, 0); - expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png"); + expect(url).toBe("api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png"); expect(init?.method).toBe("PUT"); expect(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream"); }); @@ -386,7 +386,7 @@ describe("workspace file write API", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url] = fetchCall(fetchMock, 0); - expect(url).toContain("/api/machines/remote%20a/"); + expect(url).toContain("api/machines/remote%20a/"); }); }); diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index c0cfefa..a031ec6 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -51,7 +51,7 @@ import { } from "./parsers"; import { machineGitDiffUrl, messageUrl } from "./urls"; -const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`; +const machinePrefix = (machineId = "local") => `api/machines/${encodeURIComponent(machineId)}`; type SessionLookup = SessionRef | string; @@ -100,29 +100,29 @@ function sessionBulkMutationRef(session: SessionLookup): SessionBulkMutationRef } function piWebStatusUrl(machineId: string): string { - return machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`; + return machineId === "local" ? "api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`; } export const piWebApi = { piWebStatus: (machineId = "local") => request(piWebStatusUrl(machineId), parsePiWebStatusResponse), checkForUpdates: (machineId = "local") => request(`${piWebStatusUrl(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }), - piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse), + piWebRuntime: () => request("api/pi-web/runtime", parsePiWebRuntimeResponse), }; export const machinesApi = { - machines: () => request("/api/machines", parseMachinesResponse), - addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("/api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }), - deleteMachine: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }), - health: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth), - runtime: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime), + machines: () => request("api/machines", parseMachinesResponse), + addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }), + deleteMachine: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }), + health: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth), + runtime: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime), }; function configUrl(machineId?: string): string { - return machineId === undefined ? "/api/config" : `${machinePrefix(machineId)}/config`; + return machineId === undefined ? "api/config" : `${machinePrefix(machineId)}/config`; } function pluginsUrl(machineId?: string): string { - return machineId === undefined ? "/api/plugins" : `${machinePrefix(machineId)}/plugins`; + return machineId === undefined ? "api/plugins" : `${machinePrefix(machineId)}/plugins`; } export const configApi = { @@ -135,7 +135,7 @@ export const pluginsApi = { }; function piPackageUrl(endpoint = "", machineId?: string): string { - const baseUrl = machineId === undefined ? "/api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`; + const baseUrl = machineId === undefined ? "api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`; return endpoint === "" ? baseUrl : `${baseUrl}/${endpoint}`; } diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index a851f59..19f87d1 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -145,7 +145,7 @@ function fetchCallToRoute(call: Parameters, scopedMachineId: string): function routeFromMachineUrl(method: string, input: string | URL | Request, scopedMachineId: string): ObservedHttpRoute { const url = toUrl(input); - const prefix = `/api/machines/${encodeURIComponent(scopedMachineId)}`; + const prefix = `api/machines/${encodeURIComponent(scopedMachineId)}`; if (!url.pathname.startsWith(prefix)) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`); return { method, path: url.pathname.slice(prefix.length) || "/" }; } diff --git a/src/client/src/api/sockets.test.ts b/src/client/src/api/sockets.test.ts index 56b9cbd..60f4443 100644 --- a/src/client/src/api/sockets.test.ts +++ b/src/client/src/api/sockets.test.ts @@ -24,9 +24,9 @@ describe("machine-scoped socket urls", () => { realtimeEvents(); expect(webSocketUrls).toEqual([ - "wss://pi.example.test/api/machines/local/sessions/s1/events?cwd=%2Frepo", - "wss://pi.example.test/api/machines/local/sessions/events", - "wss://pi.example.test/api/machines/local/events", + "api/machines/local/sessions/s1/events?cwd=%2Frepo", + "api/machines/local/sessions/events", + "api/machines/local/events", ]); }); @@ -34,7 +34,7 @@ describe("machine-scoped socket urls", () => { sessionEvents("s1"); expect(webSocketUrls).toEqual([ - "wss://pi.example.test/api/machines/local/sessions/s1/events", + "api/machines/local/sessions/s1/events", ]); }); @@ -42,7 +42,7 @@ describe("machine-scoped socket urls", () => { terminalSocket("p 1", "w/1", "t?1", { cols: 120, rows: 40 }, "remote-a"); expect(webSocketUrls).toEqual([ - "wss://pi.example.test/api/machines/remote-a/projects/p%201/workspaces/w%2F1/terminals/t%3F1/socket?cols=120&rows=40", + "api/machines/remote-a/projects/p%201/workspaces/w%2F1/terminals/t%3F1/socket?cols=120&rows=40", ]); }); }); diff --git a/src/client/src/api/sockets.ts b/src/client/src/api/sockets.ts index a709bd0..665d9a4 100644 --- a/src/client/src/api/sockets.ts +++ b/src/client/src/api/sockets.ts @@ -23,10 +23,9 @@ export function realtimeEvents(machineId = "local"): WebSocket { } function machinePrefix(machineId: string): string { - return `/api/machines/${encodeURIComponent(machineId)}`; + return `api/machines/${encodeURIComponent(machineId)}`; } function webSocketBaseUrl(): string { - const protocol = location.protocol === "https:" ? "wss:" : "ws:"; - return `${protocol}//${location.host}`; + return ""; } diff --git a/src/client/src/api/urls.ts b/src/client/src/api/urls.ts index b532924..c154d18 100644 --- a/src/client/src/api/urls.ts +++ b/src/client/src/api/urls.ts @@ -15,7 +15,7 @@ export function machineGitDiffUrl(machineId: string, projectId: string, workspac if (options?.path !== undefined) params.set("path", options.path); if (options?.staged === true) params.set("staged", "true"); const query = params.toString(); - return `/api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`; + return `api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`; } export function messageUrl(session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local"): string { @@ -25,14 +25,14 @@ export function messageUrl(session: SessionLookup, options?: { limit?: number; b if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.before !== undefined) params.set("before", String(options.before)); const query = params.toString(); - return `/api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`; + return `api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`; } export function workspaceFileWriteUrl(projectId: string, workspaceId: string, path: string, options?: { createDirs?: boolean; overwrite?: boolean; machineId?: string }): string { const params = new URLSearchParams({ path }); if (options?.createDirs === false) params.set("createDirs", "false"); if (options?.overwrite === false) params.set("overwrite", "false"); - const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`; + const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`; return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`; } @@ -40,6 +40,6 @@ export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, const params = new URLSearchParams(); params.set("path", path); if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt); - const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`; + const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`; return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`; } diff --git a/src/client/src/api/workspaceUploads.test.ts b/src/client/src/api/workspaceUploads.test.ts index e55226f..ffe15c7 100644 --- a/src/client/src/api/workspaceUploads.test.ts +++ b/src/client/src/api/workspaceUploads.test.ts @@ -40,7 +40,7 @@ describe("workspace upload helpers", () => { const xhr = xhrs.only(); expect(xhr.method).toBe("PUT"); - expect(xhr.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=manual%2Fhello.txt&overwrite=false"); + expect(xhr.url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=manual%2Fhello.txt&overwrite=false"); expect(xhr.headers.get("content-type")).toBe("text/plain"); expect(xhr.body).toBe(file); @@ -78,13 +78,13 @@ describe("workspace upload helpers", () => { }); const first = xhrs.at(0); - expect(first.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fa.txt"); + expect(first.url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fa.txt"); first.emitUploadProgress(1, 2); first.respondJson(200, { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }); await Promise.resolve(); const second = xhrs.at(1); - expect(second.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fb.txt"); + expect(second.url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fb.txt"); second.emitUploadProgress(3, 3); second.respondJson(200, { path: "uploads/manual/b.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true }); @@ -111,7 +111,7 @@ describe("workspace upload helpers", () => { }); const xhr = xhrs.only(); - expect(xhr.url).toBe("/api/machines/local/projects/p1/workspaces/w1/file?path=uploads%2Fnested.txt&createDirs=false"); + expect(xhr.url).toBe("api/machines/local/projects/p1/workspaces/w1/file?path=uploads%2Fnested.txt&createDirs=false"); xhr.respondJson(200, { path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }); await expect(task.promise).resolves.toEqual([ diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index fee70a6..b6ff8cf 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1488,7 +1488,7 @@ export class PiWebApp extends LitElement { const existing = this.machinePluginLoadPromises.get(machine.id); if (existing !== undefined) return existing; - const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, { + const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, { machineId: machine.id, shouldLoadPlugin: (entry) => this.plugins.shouldLoadRemotePlugin(entry.id, entry.machineSpecific), })) diff --git a/src/client/src/plugins/external.ts b/src/client/src/plugins/external.ts index 534e7a0..792457c 100644 --- a/src/client/src/plugins/external.ts +++ b/src/client/src/plugins/external.ts @@ -16,7 +16,7 @@ export interface LoadExternalPluginsOptions { shouldLoadPlugin?: (entry: PluginManifestEntry) => boolean; } -export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise { +export async function loadExternalPlugins(manifestUrl = "pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise { const manifest = await fetchPluginManifest(manifestUrl); if (manifest === undefined) return []; diff --git a/src/server/machines/machinePluginProxyRoutes.ts b/src/server/machines/machinePluginProxyRoutes.ts index 293c909..d4e0b8a 100644 --- a/src/server/machines/machinePluginProxyRoutes.ts +++ b/src/server/machines/machinePluginProxyRoutes.ts @@ -86,7 +86,7 @@ function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginMa if (modulePath === undefined) return []; return [{ ...plugin, - module: `/pi-web-plugins/${encodeURIComponent(machineScopedPluginId(machineId, plugin.id))}/${modulePath.path}${modulePath.query}`, + module: `${piWebBasePath()}/pi-web-plugins/${encodeURIComponent(machineScopedPluginId(machineId, plugin.id))}/${modulePath.path}${modulePath.query}`, }]; }), }; @@ -190,6 +190,11 @@ function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown }); } +function piWebBasePath(): string { + const basePath = process.env["PI_WEB_BASE_PATH"] ?? ""; + return basePath.replace(/\/$/u, ""); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index 45938ea..61b40dd 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -50,6 +50,11 @@ interface PiWebPluginServiceOptions { configProvider?: () => PiWebConfig; } +function piWebBasePath(): string { + const basePath = process.env["PI_WEB_BASE_PATH"] ?? ""; + return basePath.replace(/\/$/u, ""); +} + interface LocalPluginRoot { path: string; source: string; @@ -135,7 +140,7 @@ export class PiWebPluginService { private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo { return { id: plugin.id, - module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?${pluginModuleQuery(plugin)}`, + module: `${piWebBasePath()}/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?${pluginModuleQuery(plugin)}`, source: plugin.source, scope: plugin.scope, machineSpecific: plugin.machineSpecific, From c661fad7e3e25b2ae0c8789db0ce255dae2c4af8 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 22:56:37 +0200 Subject: [PATCH 094/111] fix(client): resolve URLs from application base --- src/client/src/api/clients.test.ts | 76 ++++++++++--------- src/client/src/api/clients.ts | 3 +- .../src/api/federatedRouteContract.test.ts | 14 ++-- src/client/src/api/http.ts | 4 +- src/client/src/api/sockets.test.ts | 12 +-- src/client/src/api/sockets.ts | 13 ++-- src/client/src/api/urls.ts | 9 ++- src/client/src/api/workspaceUploads.test.ts | 18 +++-- src/client/src/appUrl.test.ts | 40 ++++++++++ src/client/src/appUrl.ts | 33 ++++++++ .../sessionController.reloadSelection.test.ts | 1 + src/client/src/plugins/external.test.ts | 21 +++++ src/client/src/plugins/external.ts | 6 +- 13 files changed, 182 insertions(+), 68 deletions(-) create mode 100644 src/client/src/appUrl.test.ts create mode 100644 src/client/src/appUrl.ts create mode 100644 src/client/src/plugins/external.test.ts diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 02c972c..da00c18 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import type { PiWebConfigValues, TerminalCommandRun, Workspace } from "../../../shared/apiTypes"; import { configApi, filesApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; @@ -40,6 +40,10 @@ const commandRun: TerminalCommandRun = { metadata: {}, }; +beforeEach(() => { + vi.stubGlobal("document", { baseURI: "https://pi.example.test/" }); +}); + afterEach(() => { vi.unstubAllGlobals(); }); @@ -51,7 +55,7 @@ describe("machine-scoped runtime API", () => { await piWebApi.piWebStatus("remote a"); expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/pi-web/status"); + expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/pi-web/status"); }); it("requests an uncached update check through the local status route", async () => { @@ -80,7 +84,7 @@ describe("machine-scoped runtime API", () => { await machinesApi.runtime("remote a"); expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/runtime"); + expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime"); }); }); @@ -97,9 +101,9 @@ describe("settings config and plugin APIs", () => { await expect(pluginsApi.plugins()).resolves.toEqual(piWebPluginsResponse()); expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ - "api/config", - "api/config", - "api/plugins", + "https://pi.example.test/api/config", + "https://pi.example.test/api/config", + "https://pi.example.test/api/plugins", ]); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } }); @@ -117,9 +121,9 @@ describe("settings config and plugin APIs", () => { await expect(pluginsApi.plugins("remote a")).resolves.toEqual(piWebPluginsResponse()); expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ - "api/machines/remote%20a/config", - "api/machines/remote%20a/config", - "api/machines/remote%20a/plugins", + "https://pi.example.test/api/machines/remote%20a/config", + "https://pi.example.test/api/machines/remote%20a/config", + "https://pi.example.test/api/machines/remote%20a/plugins", ]); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } }); @@ -144,11 +148,11 @@ describe("Pi package API", () => { await piPackagesApi.update(); expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ - "api/pi-packages", - "api/pi-packages/install", - "api/pi-packages/remove", - "api/pi-packages/update", - "api/pi-packages/update", + "https://pi.example.test/api/pi-packages", + "https://pi.example.test/api/pi-packages/install", + "https://pi.example.test/api/pi-packages/remove", + "https://pi.example.test/api/pi-packages/update", + "https://pi.example.test/api/pi-packages/update", ]); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ source: "npm:@acme/new-tools" }); @@ -174,11 +178,11 @@ describe("Pi package API", () => { await piPackagesApi.update(undefined, "remote a"); expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ - "api/machines/local/pi-packages", - "api/machines/remote%20a/pi-packages", - "api/machines/remote%20a/pi-packages/install", - "api/machines/remote%20a/pi-packages/remove", - "api/machines/remote%20a/pi-packages/update", + "https://pi.example.test/api/machines/local/pi-packages", + "https://pi.example.test/api/machines/remote%20a/pi-packages", + "https://pi.example.test/api/machines/remote%20a/pi-packages/install", + "https://pi.example.test/api/machines/remote%20a/pi-packages/remove", + "https://pi.example.test/api/machines/remote%20a/pi-packages/update", ]); expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ source: "npm:@acme/new-tools" }); expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "../project-tools" }); @@ -196,10 +200,10 @@ describe("session API compatibility", () => { await expect(sessionsApi.cleanup({ archiveIdleDays: 7, projectCwds: ["/repo"] }, "remote a")).resolves.toEqual(executed); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/sessions/cleanup/preview"); + expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/cleanup/preview"); expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ archiveIdleDays: 7, deleteArchivedDays: null }); - expect(fetchCall(fetchMock, 1)[0]).toBe("api/machines/remote%20a/sessions/cleanup"); + expect(fetchCall(fetchMock, 1)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/cleanup"); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ archiveIdleDays: 7, projectCwds: ["/repo"] }); }); @@ -213,10 +217,10 @@ describe("session API compatibility", () => { await expect(sessionsApi.deleteArchivedMany([{ id: "s 1", cwd: "/repo" }], "remote a")).resolves.toEqual(deleted); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/sessions/bulk/archive"); + expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/bulk/archive"); expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }, { id: "s 2" }] }); - expect(fetchCall(fetchMock, 1)[0]).toBe("api/machines/remote%20a/sessions/bulk/delete-archived"); + expect(fetchCall(fetchMock, 1)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/bulk/delete-archived"); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] }); }); @@ -228,7 +232,7 @@ describe("session API compatibility", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchCall(fetchMock, 0); - expect(url).toBe("api/machines/remote%20a/sessions/s%201/prompt"); + expect(url).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/prompt"); expect(JSON.parse(requestBody(init))).toEqual({ text: "hello", streamingBehavior: "followUp" }); }); @@ -239,7 +243,7 @@ describe("session API compatibility", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchCall(fetchMock, 0); - expect(url).toBe("api/machines/remote%20a/sessions/s%201/prompt"); + expect(url).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/prompt"); expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo", text: "hello" }); }); }); @@ -251,7 +255,7 @@ describe("machine-scoped file suggestion API", () => { await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a", workspaceScoped: true }); expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked"); + expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked"); }); it("falls back to the legacy cwd route when workspace-scoped suggestions are not enabled", async () => { @@ -260,7 +264,7 @@ describe("machine-scoped file suggestion API", () => { await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a" }); expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo"); + expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo"); }); }); @@ -272,7 +276,7 @@ describe("machine-scoped terminal command-run API", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchCall(fetchMock, 0); - expect(url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1"); + expect(url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1"); expect(init?.method).toBe("DELETE"); }); @@ -283,7 +287,7 @@ describe("machine-scoped terminal command-run API", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchCall(fetchMock, 0); - expect(url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminal-command-runs"); + expect(url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminal-command-runs"); expect(init?.method).toBe("POST"); expect(JSON.parse(requestBody(init))).toEqual({ origin: "core", title: "Build", command: "npm test", metadata: {} }); }); @@ -295,7 +299,7 @@ describe("machine-scoped terminal command-run API", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchCall(fetchMock, 0); - expect(url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminals"); + expect(url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminals"); expect(init?.method).toBe("DELETE"); }); @@ -311,9 +315,9 @@ describe("machine-scoped terminal command-run API", () => { await terminalsApi.cancelCommandRun("run 1", "remote a"); expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ - "api/machines/remote%20a/terminal-command-runs?projectId=p+1&workspaceId=w%2F1&statuses=running&metadata=%7B%22pi.operation%22%3A%22workspace.delete%22%7D", - "api/machines/remote%20a/terminal-command-runs/run%201", - "api/machines/remote%20a/terminal-command-runs/run%201/cancel", + "https://pi.example.test/api/machines/remote%20a/terminal-command-runs?projectId=p+1&workspaceId=w%2F1&statuses=running&metadata=%7B%22pi.operation%22%3A%22workspace.delete%22%7D", + "https://pi.example.test/api/machines/remote%20a/terminal-command-runs/run%201", + "https://pi.example.test/api/machines/remote%20a/terminal-command-runs/run%201/cancel", ]); expect(fetchCall(fetchMock, 2)[1]?.method).toBe("POST"); }); @@ -323,7 +327,7 @@ describe("machine-scoped terminal command-run API", () => { await expect(terminalsApi.getCommandRun("missing", "remote-a")).resolves.toBeUndefined(); - expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote-a/terminal-command-runs/missing"); + expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote-a/terminal-command-runs/missing"); }); }); @@ -335,7 +339,7 @@ describe("workspace file write API", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchCall(fetchMock, 0); - expect(url).toBe("api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt"); + expect(url).toBe("https://pi.example.test/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt"); expect(init?.method).toBe("PUT"); expect(new Headers(init?.headers).get("content-type")).toBe("text/plain"); }); @@ -348,7 +352,7 @@ describe("workspace file write API", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchCall(fetchMock, 0); - expect(url).toBe("api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png"); + expect(url).toBe("https://pi.example.test/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png"); expect(init?.method).toBe("PUT"); expect(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream"); }); diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index a031ec6..9b12e4f 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -1,4 +1,5 @@ import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes"; +import { resolveAppUrl } from "../appUrl"; import { request } from "./http"; import { arrayOf, @@ -256,7 +257,7 @@ export const terminalsApi = { }; async function getOptionalTerminalCommandRun(runId: string, machineId: string): Promise { - const response = await fetch(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}`); + const response = await fetch(resolveAppUrl(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}`)); if (response.status === 404) return undefined; if (!response.ok) { const body: unknown = await response.json().catch((): unknown => ({})); diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 19f87d1..28a5257 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Workspace } from "../../../shared/apiTypes"; import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes"; import { activityApi, configApi, filesApi, gitApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; @@ -17,6 +17,10 @@ const workspace: Workspace = { }; const session = { id: "s 1", cwd: workspace.path }; +beforeEach(() => { + vi.stubGlobal("document", { baseURI: "https://pi.example.test/" }); +}); + afterEach(() => { vi.unstubAllGlobals(); }); @@ -113,7 +117,6 @@ describe("federated route contract", () => { webSocketUrls.push(url); } vi.stubGlobal("WebSocket", FakeWebSocket); - vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" }); sessionEvents(session, machineId); globalSessionEvents(machineId); @@ -145,9 +148,10 @@ function fetchCallToRoute(call: Parameters, scopedMachineId: string): function routeFromMachineUrl(method: string, input: string | URL | Request, scopedMachineId: string): ObservedHttpRoute { const url = toUrl(input); - const prefix = `api/machines/${encodeURIComponent(scopedMachineId)}`; - if (!url.pathname.startsWith(prefix)) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`); - return { method, path: url.pathname.slice(prefix.length) || "/" }; + const prefix = `/api/machines/${encodeURIComponent(scopedMachineId)}`; + const prefixIndex = url.pathname.lastIndexOf(prefix); + if (prefixIndex === -1) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`); + return { method, path: url.pathname.slice(prefixIndex + prefix.length) || "/" }; } function toUrl(input: string | URL | Request): URL { diff --git a/src/client/src/api/http.ts b/src/client/src/api/http.ts index dd553ff..c101bf3 100644 --- a/src/client/src/api/http.ts +++ b/src/client/src/api/http.ts @@ -1,7 +1,9 @@ +import { resolveAppUrl } from "../appUrl"; + export async function request(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise { const headers = new Headers(init?.headers); if (init?.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json"); - const response = await fetch(url, { ...init, headers }); + const response = await fetch(resolveAppUrl(url), { ...init, headers }); if (!response.ok) { const body: unknown = await response.json().catch((): unknown => ({})); throw new Error(errorMessage(body) ?? response.statusText); diff --git a/src/client/src/api/sockets.test.ts b/src/client/src/api/sockets.test.ts index 60f4443..37a7f2f 100644 --- a/src/client/src/api/sockets.test.ts +++ b/src/client/src/api/sockets.test.ts @@ -10,7 +10,7 @@ function FakeWebSocket(url: string): void { beforeEach(() => { webSocketUrls.length = 0; vi.stubGlobal("WebSocket", FakeWebSocket); - vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" }); + vi.stubGlobal("document", { baseURI: "https://pi.example.test/" }); }); afterEach(() => { @@ -24,9 +24,9 @@ describe("machine-scoped socket urls", () => { realtimeEvents(); expect(webSocketUrls).toEqual([ - "api/machines/local/sessions/s1/events?cwd=%2Frepo", - "api/machines/local/sessions/events", - "api/machines/local/events", + "wss://pi.example.test/api/machines/local/sessions/s1/events?cwd=%2Frepo", + "wss://pi.example.test/api/machines/local/sessions/events", + "wss://pi.example.test/api/machines/local/events", ]); }); @@ -34,7 +34,7 @@ describe("machine-scoped socket urls", () => { sessionEvents("s1"); expect(webSocketUrls).toEqual([ - "api/machines/local/sessions/s1/events", + "wss://pi.example.test/api/machines/local/sessions/s1/events", ]); }); @@ -42,7 +42,7 @@ describe("machine-scoped socket urls", () => { terminalSocket("p 1", "w/1", "t?1", { cols: 120, rows: 40 }, "remote-a"); expect(webSocketUrls).toEqual([ - "api/machines/remote-a/projects/p%201/workspaces/w%2F1/terminals/t%3F1/socket?cols=120&rows=40", + "wss://pi.example.test/api/machines/remote-a/projects/p%201/workspaces/w%2F1/terminals/t%3F1/socket?cols=120&rows=40", ]); }); }); diff --git a/src/client/src/api/sockets.ts b/src/client/src/api/sockets.ts index 665d9a4..aa917e9 100644 --- a/src/client/src/api/sockets.ts +++ b/src/client/src/api/sockets.ts @@ -1,4 +1,5 @@ import type { SessionRef } from "../../../shared/apiTypes"; +import { resolveAppWebSocketUrl } from "../appUrl"; type SessionLookup = SessionRef | string; @@ -6,26 +7,22 @@ export function sessionEvents(session: SessionLookup, machineId = "local"): WebS const cwd = typeof session === "string" ? undefined : session.cwd; const query = cwd === undefined || cwd === "" ? "" : `?${new URLSearchParams({ cwd }).toString()}`; const sessionId = typeof session === "string" ? session : session.id; - return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId)}/events${query}`); + return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId)}/events${query}`)); } export function globalSessionEvents(machineId = "local"): WebSocket { - return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/events`); + return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/sessions/events`)); } export function terminalSocket(projectId: string, workspaceId: string, terminalId: string, initialSize?: { cols: number; rows: number }, machineId = "local"): WebSocket { const sizeQuery = initialSize === undefined ? "" : `?cols=${encodeURIComponent(String(initialSize.cols))}&rows=${encodeURIComponent(String(initialSize.rows))}`; - return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`); + return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`)); } export function realtimeEvents(machineId = "local"): WebSocket { - return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/events`); + return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/events`)); } function machinePrefix(machineId: string): string { return `api/machines/${encodeURIComponent(machineId)}`; } - -function webSocketBaseUrl(): string { - return ""; -} diff --git a/src/client/src/api/urls.ts b/src/client/src/api/urls.ts index c154d18..b84aa88 100644 --- a/src/client/src/api/urls.ts +++ b/src/client/src/api/urls.ts @@ -1,4 +1,5 @@ import type { SessionRef } from "../../../shared/apiTypes"; +import { resolveAppUrl } from "../appUrl"; type SessionLookup = SessionRef | string; @@ -15,7 +16,7 @@ export function machineGitDiffUrl(machineId: string, projectId: string, workspac if (options?.path !== undefined) params.set("path", options.path); if (options?.staged === true) params.set("staged", "true"); const query = params.toString(); - return `api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`; + return resolveAppUrl(`api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`); } export function messageUrl(session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local"): string { @@ -25,7 +26,7 @@ export function messageUrl(session: SessionLookup, options?: { limit?: number; b if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.before !== undefined) params.set("before", String(options.before)); const query = params.toString(); - return `api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`; + return resolveAppUrl(`api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`); } export function workspaceFileWriteUrl(projectId: string, workspaceId: string, path: string, options?: { createDirs?: boolean; overwrite?: boolean; machineId?: string }): string { @@ -33,7 +34,7 @@ export function workspaceFileWriteUrl(projectId: string, workspaceId: string, pa if (options?.createDirs === false) params.set("createDirs", "false"); if (options?.overwrite === false) params.set("overwrite", "false"); const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`; - return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`; + return resolveAppUrl(`${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`); } export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string { @@ -41,5 +42,5 @@ export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, params.set("path", path); if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt); const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`; - return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`; + return resolveAppUrl(`${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`); } diff --git a/src/client/src/api/workspaceUploads.test.ts b/src/client/src/api/workspaceUploads.test.ts index ffe15c7..4eb5e5b 100644 --- a/src/client/src/api/workspaceUploads.test.ts +++ b/src/client/src/api/workspaceUploads.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { effectiveWorkspaceUploadFolder, uploadWorkspaceFile, @@ -12,6 +12,14 @@ import { type WorkspaceUploadXhr, } from "./workspaceUploads"; +beforeEach(() => { + vi.stubGlobal("document", { baseURI: "https://pi.example.test/" }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe("workspace upload helpers", () => { it("resolves effective upload defaults and workspace-relative paths", () => { expect(effectiveWorkspaceUploadFolder(undefined)).toBe(".pi-web/uploads"); @@ -40,7 +48,7 @@ describe("workspace upload helpers", () => { const xhr = xhrs.only(); expect(xhr.method).toBe("PUT"); - expect(xhr.url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=manual%2Fhello.txt&overwrite=false"); + expect(xhr.url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=manual%2Fhello.txt&overwrite=false"); expect(xhr.headers.get("content-type")).toBe("text/plain"); expect(xhr.body).toBe(file); @@ -78,13 +86,13 @@ describe("workspace upload helpers", () => { }); const first = xhrs.at(0); - expect(first.url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fa.txt"); + expect(first.url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fa.txt"); first.emitUploadProgress(1, 2); first.respondJson(200, { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }); await Promise.resolve(); const second = xhrs.at(1); - expect(second.url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fb.txt"); + expect(second.url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fb.txt"); second.emitUploadProgress(3, 3); second.respondJson(200, { path: "uploads/manual/b.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true }); @@ -111,7 +119,7 @@ describe("workspace upload helpers", () => { }); const xhr = xhrs.only(); - expect(xhr.url).toBe("api/machines/local/projects/p1/workspaces/w1/file?path=uploads%2Fnested.txt&createDirs=false"); + expect(xhr.url).toBe("https://pi.example.test/api/machines/local/projects/p1/workspaces/w1/file?path=uploads%2Fnested.txt&createDirs=false"); xhr.respondJson(200, { path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }); await expect(task.promise).resolves.toEqual([ diff --git a/src/client/src/appUrl.test.ts b/src/client/src/appUrl.test.ts new file mode 100644 index 0000000..0136b10 --- /dev/null +++ b/src/client/src/appUrl.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { resolveAppUrl, resolveAppWebSocketUrl, type AppUrlContext } from "./appUrl"; + +const rootHttpContext: AppUrlContext = { + viteBaseUrl: "/", + documentBaseUrl: "http://pi.example.test/", +}; + +const nestedHttpsContext: AppUrlContext = { + viteBaseUrl: "./", + documentBaseUrl: "https://pi.example.test/test/ai/", +}; + +describe("application URLs", () => { + it("resolves app-owned paths at an HTTP root deployment", () => { + expect(resolveAppUrl("api/pi-web/status", rootHttpContext)).toBe("http://pi.example.test/api/pi-web/status"); + expect(resolveAppUrl("/pi-web-plugins/manifest.json", rootHttpContext)).toBe("http://pi.example.test/pi-web-plugins/manifest.json"); + }); + + it("resolves paths within a canonical nested HTTPS deployment", () => { + expect(resolveAppUrl("api/pi-web/status", nestedHttpsContext)).toBe("https://pi.example.test/test/ai/api/pi-web/status"); + expect(resolveAppUrl("/pi-web-plugins/manifest.json", nestedHttpsContext)).toBe("https://pi.example.test/test/ai/pi-web-plugins/manifest.json"); + }); + + it("preserves encoded path segments and query parameters", () => { + expect(resolveAppUrl("api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one&before=10", nestedHttpsContext)) + .toBe("https://pi.example.test/test/ai/api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one&before=10"); + }); +}); + +describe("application WebSocket URLs", () => { + it("maps root HTTP URLs to absolute ws URLs", () => { + expect(resolveAppWebSocketUrl("api/machines/local/events", rootHttpContext)).toBe("ws://pi.example.test/api/machines/local/events"); + }); + + it("maps nested HTTPS URLs to absolute wss URLs without losing path or query data", () => { + expect(resolveAppWebSocketUrl("api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one", nestedHttpsContext)) + .toBe("wss://pi.example.test/test/ai/api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one"); + }); +}); diff --git a/src/client/src/appUrl.ts b/src/client/src/appUrl.ts new file mode 100644 index 0000000..72d6c36 --- /dev/null +++ b/src/client/src/appUrl.ts @@ -0,0 +1,33 @@ +export interface AppUrlContext { + viteBaseUrl: string; + documentBaseUrl: string; +} + +export function resolveAppUrl(path: string, context: AppUrlContext = browserAppUrlContext()): string { + const applicationBaseUrl = new URL(context.viteBaseUrl, context.documentBaseUrl); + return new URL(appRelativePath(path), applicationBaseUrl).toString(); +} + +export function resolveAppWebSocketUrl(path: string, context: AppUrlContext = browserAppUrlContext()): string { + const url = new URL(resolveAppUrl(path, context)); + if (url.protocol === "http:") { + url.protocol = "ws:"; + } else if (url.protocol === "https:") { + url.protocol = "wss:"; + } else { + throw new Error(`Cannot create a WebSocket URL from ${url.protocol}`); + } + return url.toString(); +} + +function browserAppUrlContext(): AppUrlContext { + return { + viteBaseUrl: import.meta.env.BASE_URL, + documentBaseUrl: document.baseURI, + }; +} + +function appRelativePath(path: string): string { + // A leading slash means the application root, not the origin root, so it must stay within nested deployments. + return path.startsWith("/") ? `.${path}` : path; +} diff --git a/src/client/src/controllers/sessionController.reloadSelection.test.ts b/src/client/src/controllers/sessionController.reloadSelection.test.ts index 62785cf..3b98501 100644 --- a/src/client/src/controllers/sessionController.reloadSelection.test.ts +++ b/src/client/src/controllers/sessionController.reloadSelection.test.ts @@ -32,6 +32,7 @@ describe("SessionController reload and selection", () => { return Promise.resolve(freshPage); }, status: (session) => Promise.resolve(status(sessionLookupId(session))), + thinkingLevels: () => Promise.resolve({ levels: [] }), }; const controller = new SessionController( () => state, diff --git a/src/client/src/plugins/external.test.ts b/src/client/src/plugins/external.test.ts new file mode 100644 index 0000000..bd3c224 --- /dev/null +++ b/src/client/src/plugins/external.test.ts @@ -0,0 +1,21 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loadExternalPlugins } from "./external"; + +beforeEach(() => { + vi.stubGlobal("document", { baseURI: "https://pi.example.test/" }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("external plugin manifests", () => { + it("fetches the default manifest through the application base", async () => { + const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))); + vi.stubGlobal("fetch", fetchMock); + + await expect(loadExternalPlugins()).resolves.toEqual([]); + + expect(fetchMock).toHaveBeenCalledWith("https://pi.example.test/pi-web-plugins/manifest.json", { cache: "no-store" }); + }); +}); diff --git a/src/client/src/plugins/external.ts b/src/client/src/plugins/external.ts index 792457c..9b598b2 100644 --- a/src/client/src/plugins/external.ts +++ b/src/client/src/plugins/external.ts @@ -1,4 +1,5 @@ import { machineScopedPluginId } from "../../../shared/machinePluginIds"; +import { resolveAppUrl } from "../appUrl"; import type { PiWebPlugin, PiWebPluginRegistration } from "./types"; export interface PluginManifestEntry { @@ -17,14 +18,15 @@ export interface LoadExternalPluginsOptions { } export async function loadExternalPlugins(manifestUrl = "pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise { - const manifest = await fetchPluginManifest(manifestUrl); + const resolvedManifestUrl = resolveAppUrl(manifestUrl); + const manifest = await fetchPluginManifest(resolvedManifestUrl); if (manifest === undefined) return []; const registrations: PiWebPluginRegistration[] = []; for (const entry of manifest.plugins) { if (options.shouldLoadPlugin?.(entry) === false) continue; try { - const moduleUrl = new URL(entry.module, new URL(manifestUrl, window.location.href)).toString(); + const moduleUrl = new URL(entry.module, resolvedManifestUrl).toString(); const module: unknown = await import(/* @vite-ignore */ moduleUrl); const plugin = parsePluginModule(module, moduleUrl); registrations.push({ From 4b2882bb5c53db2dbd798f9822a4cf3374724073 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 23:02:08 +0200 Subject: [PATCH 095/111] fix(client): make build assets deployment relative --- src/client/index.html | 6 ++-- src/client/public/manifest.webmanifest | 8 ++--- src/clientBuildContents.test.ts | 45 ++++++++++++++++++++++++++ vite.config.ts | 1 + 4 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 src/clientBuildContents.test.ts diff --git a/src/client/index.html b/src/client/index.html index 578d7db..3b8cdad 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -5,9 +5,9 @@ PI WEB - - - + + +