From 74927a76408a0c8abdc5af12591482f73ff6a7ac Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 11 Oct 2026 01:01:51 -0400 Subject: [PATCH] fix: v0.9.1 robustness cleanups - run_resume falls back to a new conversation only when the transcript file is gone; other failures end at the menu with the recorded id intact. - The SessionStart hook resolves the session name under the registry lock; rename holds it across tmux rename-session and the registry move (dd_registry_locked, nestable). - dd_load_env parses KEY=value like systemd instead of sourcing the files. - devdesk-deploy: DPkg::Lock::Timeout, explicit FAILED lines on stdout and in the log; the hook answers synchronously and CI checks the HTTP status. logrotate for the deploy log. - postinst drops duplicate terminal-features entries; hooks sweep stale temp files. Closes #12 Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/build-deb.yml | 9 +++- bin/devdesk | 14 +++-- debian/changelog | 25 +++++++++ debian/devdesk.logrotate | 8 +++ debian/devdesk.postinst | 7 +++ docs/DEPLOY.md | 6 ++- etc/env | 5 +- libexec/devdesk-claude-run | 21 ++++---- libexec/devdesk-deploy | 41 +++++++++++---- libexec/devdesk-history.sh | 9 ---- libexec/devdesk-inbox.sh | 3 +- libexec/devdesk-lib.sh | 94 ++++++++++++++++++++++++++-------- libexec/devdesk-session-hook | 16 ++++-- tests/attach.bats | 87 +++++++++++++++++++++++++++++-- 14 files changed, 277 insertions(+), 68 deletions(-) create mode 100644 debian/devdesk.logrotate diff --git a/.gitea/workflows/build-deb.yml b/.gitea/workflows/build-deb.yml index c7b155e..610a649 100644 --- a/.gitea/workflows/build-deb.yml +++ b/.gitea/workflows/build-deb.yml @@ -157,6 +157,8 @@ jobs: steps: # CT 126's webhook listener runs /usr/lib/devdesk/devdesk-deploy, which # waits for this version in the apt registry and upgrades the package. + # The hook answers when the install is done (HTTP 500 if it failed), so + # this job's result is the deploy's result. - name: Trigger devdesk-deploy webhook env: WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }} @@ -164,12 +166,15 @@ jobs: run: | PAYLOAD="{\"ref\":\"${GIT_REF}\"}" SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}') - curl -sf --connect-timeout 10 \ + code=$(curl -sS --connect-timeout 10 --max-time 900 -o /tmp/deploy.out -w '%{http_code}' \ -X POST \ -H "Content-Type: application/json" \ -H "X-Gitea-Signature: ${SIG}" \ -d "$PAYLOAD" \ - "http://10.10.10.58:9000/hooks/devdesk-deploy" + "http://10.10.10.58:9000/hooks/devdesk-deploy") || code=000 + cat /tmp/deploy.out 2>/dev/null || true + echo "webhook: HTTP $code" + [ "$code" = 200 ] notify-failure: name: Notify on failure diff --git a/bin/devdesk b/bin/devdesk index c23ec85..9eb5e8f 100755 --- a/bin/devdesk +++ b/bin/devdesk @@ -141,11 +141,17 @@ cmd_rename() { if dd_server_up && dd_tmux has-session -t "=$old" 2>/dev/null; then running=1; fi [ "$running" = 1 ] || [ -n "$(dd_registry_get "$old" dir)" ] || dd_die "no session named '$old'" ! session_exists "$new" || dd_die "a session named '$new' already exists" - if [ "$running" = 1 ]; then - dd_tmux rename-session -t "=$old" "$new" - dd_tmux set-environment -t "=$new" DEVDESK_SESSION "$new" + dd_registry_locked rename_locked "$old" "$new" "$running" || dd_die "could not rename '$old'" +} + +# tmux and the registry change together, under the registry lock (see +# devdesk-session-hook). +rename_locked() { + if [ "$3" = 1 ]; then + dd_tmux rename-session -t "=$1" "$2" || return 1 + dd_tmux set-environment -t "=$2" DEVDESK_SESSION "$2" fi - dd_registry_rename "$old" "$new" + dd_registry_rename "$1" "$2" } cmd_end() { diff --git a/debian/changelog b/debian/changelog index b6b0220..ebd7cf2 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,28 @@ +devdesk (0.9.1) bookworm; urgency=medium + + * Robustness (#12): + - A resume that fails (an early Ctrl+C, a network error) no longer + replaces the session's recorded conversation with a new one: Claude + starts fresh only when the conversation's file is really gone; + otherwise the menu offers to try again. + - The SessionStart hook reads the session name under the registry lock + that rename holds, so renaming during /clear or a restart can't leave + the conversation id under the old name. + - Env files are parsed the way systemd parses them instead of being + sourced: values are literal (no $ expansion or command substitution), + and bad lines are reported and skipped. + - Deploys wait for the dpkg lock (120 s), log an explicit "deploy + FAILED" line, and the webhook answers with the result, so CI's deploy + job fails (and alerts) when the install fails. The deploy log is + rotated monthly. + - postinst removes the duplicate tmux terminal-features entries left by + upgrades before 0.3.1. + - Temp files of hooks killed mid-write are swept. + - NPM now sends X-Frame-Options DENY and CSP frame-ancestors 'none' for + dev.lotusguild.org (proxy config, not part of the package). + + -- Jared Vititoe Sun, 11 Oct 2026 01:01:33 -0400 + devdesk (0.9.0) bookworm; urgency=medium * Search past conversations (#24): every Claude conversation on this host diff --git a/debian/devdesk.logrotate b/debian/devdesk.logrotate new file mode 100644 index 0000000..363d552 --- /dev/null +++ b/debian/devdesk.logrotate @@ -0,0 +1,8 @@ +/var/log/devdesk-deploy.log { + monthly + rotate 6 + compress + delaycompress + missingok + notifempty +} diff --git a/debian/devdesk.postinst b/debian/devdesk.postinst index 07ce83e..031a71f 100644 --- a/debian/devdesk.postinst +++ b/debian/devdesk.postinst @@ -13,6 +13,13 @@ configure) # Apply tmux.conf changes to a running server without restarting it. if [ -S /run/devdesk/tmux.sock ]; then tmux -N -S /run/devdesk/tmux.sock source-file /usr/share/devdesk/tmux.conf >/dev/null 2>&1 || true + # Before 0.3.1 every upgrade appended another copy of DevDesk's + # terminal-features entry; tmux.conf now sets index 90. Drop the copies. + tmux -N -S /run/devdesk/tmux.sock show -s terminal-features 2>/dev/null | + awk '$2 == "xterm-256color:RGB" && $1 != "terminal-features[90]" { print $1 }' | + while read -r opt; do + tmux -N -S /run/devdesk/tmux.sock set -su "$opt" >/dev/null 2>&1 || true + done fi ;; esac diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index e7f0bec..645357f 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -37,7 +37,9 @@ Then run `authelia validate-config --config /etc/authelia/configuration.yml && s - **Pi-hole:** add `"10.10.10.27 dev.lotusguild.org"` to `dns.hosts` in `pve-infra/containers/163-pihole/etc/pihole/pihole.toml`. A push auto-deploys it, and LAN clients then reach NPM directly instead of hairpinning. ## 5. Auto-deploy on release -A `v*` tag makes CI publish the package, then send a signed webhook (the house HMAC pattern) to CT 126's existing `webhook` listener on :9000. The listener runs `/usr/lib/devdesk/devdesk-deploy `, which waits for that version in the apt registry and upgrades only `devdesk` with `--force-confold`. Running Claude sessions are untouched. The log is `/var/log/devdesk-deploy.log`. +A `v*` tag makes CI publish the package, then send a signed webhook (the house HMAC pattern) to CT 126's existing `webhook` listener on :9000. The listener runs `/usr/lib/devdesk/devdesk-deploy `, which waits for that version in the apt registry and upgrades only `devdesk` with `--force-confold`. Running Claude sessions are untouched. The log is `/var/log/devdesk-deploy.log` (rotated monthly). + +The hook answers when the deploy has finished, with the script's last line, and with HTTP 500 if it failed. CI's **Deploy to CT 126** job therefore fails when the install fails, and the failure alert goes to Matrix. **Setup, done once:** 1. Generate a secret. @@ -48,6 +50,8 @@ A `v*` tag makes CI publish the package, then send a signed webhook (the house H "execute-command": "/usr/lib/devdesk/devdesk-deploy", "command-working-directory": "/", "pass-arguments-to-command": [{"source": "payload", "name": "ref"}], + "include-command-output-in-response": true, + "include-command-output-in-response-on-error": true, "trigger-rule": {"and": [ {"match": {"type": "payload-hash-sha256", "secret": "", "parameter": {"source": "header", "name": "X-Gitea-Signature"}}}, diff --git a/etc/env b/etc/env index 61dca65..2d8b8ce 100644 --- a/etc/env +++ b/etc/env @@ -1,8 +1,9 @@ # DevDesk defaults, shipped by the package. Don't edit: put your changes in # /etc/devdesk/env.local (same KEY=value format, read after this file), so # package upgrades never prompt about this file. -# Read by systemd (EnvironmentFile=) and by the devdesk scripts. Plain KEY=value -# lines only: systemd does not support inline comments. +# Read by systemd (EnvironmentFile=) and by the devdesk scripts, the same way: +# plain KEY=value lines, values taken literally (no $VAR, no $(…)), optionally +# in quotes. No inline comments. Other lines are ignored with a warning. # The ttyd credential lives in /etc/devdesk/secret.env (generated on install). # Port ttyd listens on (NPM proxies dev.lotusguild.org here). diff --git a/libexec/devdesk-claude-run b/libexec/devdesk-claude-run index 3545d09..7c51338 100755 --- a/libexec/devdesk-claude-run +++ b/libexec/devdesk-claude-run @@ -42,12 +42,21 @@ run_new() { } run_resume() { - local id args started rc fork=() + local id args fork=() id=$(dd_registry_get "$NAME" claude_session_id) if [ -z "$id" ]; then run_new return fi + # Start fresh only when the conversation's file is really gone. Any other + # failure (an early Ctrl+C, a network error) leaves the recorded id alone + # and ends at the menu, where ⏎ tries again (devdesk#12). + if [ -z "$(dd_convo_file "$id")" ]; then + printf "%sConversation %s is no longer in Claude's history — starting a new one.%s\n" "$MUTED" "$id" "$RESET" + sleep 1 + run_new + return + fi mapfile -d '' args < <(common_args) # "Fork" from conversation search (devdesk#24): continue as a new # conversation, once; the SessionStart hook then records the new id. @@ -56,17 +65,7 @@ run_resume() { # shellcheck disable=SC2016 # $n is a jq variable dd_registry_update 'del(.sessions[$n].fork)' --arg n "$NAME" 2>/dev/null || true fi - started=$SECONDS "$CLAUDE_BIN" --resume "$id" "${fork[@]}" "${args[@]}" - rc=$? - # A resume that fails straight away means the transcript is gone; start fresh. - if [ "$rc" -ne 0 ] && [ $((SECONDS - started)) -lt 10 ]; then - printf '%sCould not resume conversation %s — starting a new one.%s\n' "$MUTED" "$id" "$RESET" - sleep 1 - run_new - return - fi - return "$rc" } menu() { diff --git a/libexec/devdesk-deploy b/libexec/devdesk-deploy index da4aca2..c3d8fc5 100755 --- a/libexec/devdesk-deploy +++ b/libexec/devdesk-deploy @@ -3,6 +3,9 @@ # listener (hook "devdesk-deploy") when CI publishes a v* tag; the tag's ref is # passed as $1. Safe for running sessions: package upgrades restart only # devdesk.service (ttyd), never devdesk-tmux.service where Claude lives. +# +# The hook runs this synchronously and returns its output, with HTTP 500 when +# it fails, so CI's "Deploy to CT 126" job shows the real result (devdesk#12). set -euo pipefail LOG=${DEVDESK_DEPLOY_LOG:-/var/log/devdesk-deploy.log} @@ -12,11 +15,28 @@ log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$LOG" } -exec 9>/run/devdesk-deploy.lock -flock -w 900 9 || { - log "another deploy is still running; giving up" +# The outcome goes to the log and to stdout (the webhook response). +say() { + log "$*" + printf '%s\n' "$*" +} +fail() { + say "deploy FAILED: $*" exit 1 } +on_error() { + local rc=$? + say "deploy FAILED: line $1 exited $rc (details in $LOG)" + exit "$rc" +} +trap 'on_error $LINENO' ERR + +# Wait for dpkg/apt rather than fail when unattended-upgrades or a manual apt +# run holds the lock. +APT_OPTS=(-o DPkg::Lock::Timeout=120 -o Dpkg::Use-Pty=0) + +exec 9>/run/devdesk-deploy.lock +flock -w 900 9 || fail "another deploy is still running" want=${1:-} want=${want#refs/tags/} @@ -25,7 +45,7 @@ before=$(dpkg-query -W -f='${Version}' devdesk 2>/dev/null || echo none) log "=== deploy: installed $before, release ${want:-latest}" refresh() { - apt-get update -q -o Dir::Etc::sourcelist="$SOURCES" -o Dir::Etc::sourceparts=- \ + apt-get update -q "${APT_OPTS[@]}" -o Dir::Etc::sourcelist="$SOURCES" -o Dir::Etc::sourceparts=- \ -o APT::Get::List-Cleanup=0 >>"$LOG" 2>&1 } @@ -41,14 +61,17 @@ for _ in $(seq 1 18); do done if [ -n "$want" ] && [ "$candidate" != "$want" ]; then - log "registry still offers $candidate, not $want; giving up" - exit 1 + fail "the registry still offers ${candidate:-nothing}, not $want" fi if [ "$candidate" = "$before" ]; then - log "already at $before; nothing to do" + say "already at $before; nothing to do" exit 0 fi -DEBIAN_FRONTEND=noninteractive apt-get install -y -q --only-upgrade \ +DEBIAN_FRONTEND=noninteractive apt-get install -y -q --only-upgrade "${APT_OPTS[@]}" \ -o Dpkg::Options::=--force-confold devdesk >"$LOG" 2>&1 -log "devdesk $before -> $(dpkg-query -W -f='${Version}' devdesk)" +after=$(dpkg-query -W -f='${Version}' devdesk) +if [ -n "$want" ] && [ "$after" != "$want" ]; then + fail "installed $after, not $want" +fi +say "devdesk $before -> $after" diff --git a/libexec/devdesk-history.sh b/libexec/devdesk-history.sh index e6148cb..5cf8ecf 100644 --- a/libexec/devdesk-history.sh +++ b/libexec/devdesk-history.sh @@ -2,15 +2,6 @@ # Past conversations (devdesk#24) and client presence (devdesk#26), sourced by # bin/devdesk and reached through `devdesk attach --search|--resume-convo|--presence`. -dd_projects_dir() { - printf '%s/projects\n' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" -} - -dd_convo_file() { - [[ $1 =~ ^[0-9a-f-]{36}$ ]] || return 1 - compgen -G "$(dd_projects_dir)/*/$1.jsonl" | head -n1 -} - # --search QUERY: full-text search over every Claude conversation on this host # (DevDesk and code-server alike). Case-insensitive, plain text. Newest first; # per conversation: title, folder, dates and up to 3 snippets (what you asked, diff --git a/libexec/devdesk-inbox.sh b/libexec/devdesk-inbox.sh index 83f969b..c7740a7 100644 --- a/libexec/devdesk-inbox.sh +++ b/libexec/devdesk-inbox.sh @@ -50,8 +50,7 @@ cmd_answer() { dd_transcript() { local id id=$(dd_registry_get "$1" claude_session_id) - [[ $id =~ ^[0-9a-f-]{36}$ ]] || return 1 - compgen -G "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/*/$id.jsonl" | head -n1 + dd_convo_file "$id" } # --peek SESSION: what the session is doing and the last exchange, from the end diff --git a/libexec/devdesk-lib.sh b/libexec/devdesk-lib.sh index 9bcef0d..1362099 100644 --- a/libexec/devdesk-lib.sh +++ b/libexec/devdesk-lib.sh @@ -8,13 +8,34 @@ DEVDESK_ENV=${DEVDESK_ENV:-/etc/devdesk/env} # Local overrides go in env.local: it is never shipped, so upgrades don't prompt about it. DEVDESK_ENV_LOCAL=${DEVDESK_ENV_LOCAL:-${DEVDESK_ENV}.local} +# The env files are read by systemd (EnvironmentFile=) as well, so they are +# parsed here the way systemd parses them, never sourced: KEY=value lines, the +# value taken literally (no $ expansion, no command substitution), optionally +# in matching quotes. Anything else is reported and skipped (devdesk#12). dd_load_env() { - local f - for f in "$DEVDESK_ENV" "$DEVDESK_ENV_LOCAL"; do - if [ -r "$f" ]; then - # shellcheck source=/dev/null - . "$f" - fi + local _dd_f _dd_line _dd_n _dd_key _dd_val + for _dd_f in "$DEVDESK_ENV" "$DEVDESK_ENV_LOCAL"; do + [ -r "$_dd_f" ] || continue + _dd_n=0 + while IFS= read -r _dd_line || [ -n "$_dd_line" ]; do + _dd_n=$((_dd_n + 1)) + _dd_line=${_dd_line%$'\r'} + [[ $_dd_line =~ ^[[:space:]]*([#\;].*)?$ ]] && continue + if ! [[ $_dd_line =~ ^[[:space:]]*([A-Z_][A-Z0-9_]*)=(.*)$ ]]; then + printf 'devdesk: %s:%s: not a KEY=value line, ignored\n' "$_dd_f" "$_dd_n" >&2 + continue + fi + _dd_key=${BASH_REMATCH[1]} + _dd_val=${BASH_REMATCH[2]} + _dd_val=${_dd_val%"${_dd_val##*[![:space:]]}"} # trailing blanks, like systemd + if [[ $_dd_val =~ ^\"(.*)\"$ || $_dd_val =~ ^\'(.*)\'$ ]]; then + _dd_val=${BASH_REMATCH[1]} + elif [[ $_dd_val == [\"\']* ]]; then + printf 'devdesk: %s:%s: unmatched quote, ignored\n' "$_dd_f" "$_dd_n" >&2 + continue + fi + printf -v "$_dd_key" '%s' "$_dd_val" + done <"$_dd_f" done } dd_load_env @@ -101,26 +122,59 @@ dd_registry_get() { jq -Mr --arg n "$1" --arg f "$2" '.sessions[$n][$f] // empty' "$DEVDESK_REGISTRY" 2>/dev/null } -# dd_registry_update JQ_FILTER [jq args...] — read-modify-write under flock. -dd_registry_update() { - local filter=$1 - shift +# dd_registry_locked CMD [ARGS...] — run CMD holding the registry lock, so a +# step that reads tmux and then writes the registry (the SessionStart hook, +# rename) can't interleave with another. Nests: an update inside reuses it. +dd_registry_locked() { + if [ -n "${DD_REGISTRY_LOCKED:-}" ]; then + "$@" + return + fi mkdir -p "$DEVDESK_STATE_DIR" ( flock -w 5 9 || exit 1 - [ -s "$DEVDESK_REGISTRY" ] || printf '{"sessions":{}}\n' >"$DEVDESK_REGISTRY" - local tmp - tmp=$(mktemp "$DEVDESK_REGISTRY.XXXXXX") || exit 1 - if jq "$@" "$filter" "$DEVDESK_REGISTRY" >"$tmp"; then - chmod 600 "$tmp" - mv -f "$tmp" "$DEVDESK_REGISTRY" - else - rm -f "$tmp" - exit 1 - fi + DD_REGISTRY_LOCKED=1 + "$@" ) 9>"$DEVDESK_REGISTRY.lock" } +# dd_registry_update JQ_FILTER [jq args...] — read-modify-write under flock. +dd_registry_update() { + dd_registry_locked _dd_registry_apply "$@" +} + +_dd_registry_apply() { + local filter=$1 tmp + shift + [ -s "$DEVDESK_REGISTRY" ] || printf '{"sessions":{}}\n' >"$DEVDESK_REGISTRY" + tmp=$(mktemp "$DEVDESK_REGISTRY.XXXXXX") || return 1 + if jq "$@" "$filter" "$DEVDESK_REGISTRY" >"$tmp"; then + chmod 600 "$tmp" + mv -f "$tmp" "$DEVDESK_REGISTRY" + else + rm -f "$tmp" + return 1 + fi +} + +# Claude's conversation files: ~/.claude/projects//.jsonl. +dd_projects_dir() { + printf '%s/projects\n' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +} + +dd_convo_file() { + [[ $1 =~ ^[0-9a-f-]{36}$ ]] || return 1 + compgen -G "$(dd_projects_dir)/*/$1.jsonl" | head -n1 +} + +# Temp files of writers that were killed mid-write (hooks run under Claude's +# timeouts): ..XXXXXX and registry.json.XXXXXX, older than 10 minutes. +dd_sweep_tmp() { + find "$DEVDESK_CONTEXT_DIR" "$DEVDESK_STATUS_DIR" -maxdepth 1 -type f -name '.*.??????' -mmin +10 -delete 2>/dev/null + find "$DEVDESK_STATE_DIR" -maxdepth 1 -type f -name 'registry.json.??????' -mmin +10 -delete 2>/dev/null + return 0 +} + dd_registry_set_dir() { dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, updated: $t})' \ --arg n "$1" --arg d "$2" --arg t "$(date -Is)" diff --git a/libexec/devdesk-session-hook b/libexec/devdesk-session-hook index 418680a..db22271 100755 --- a/libexec/devdesk-session-hook +++ b/libexec/devdesk-session-hook @@ -7,14 +7,20 @@ # shellcheck source=libexec/devdesk-lib.sh . "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" || exit 0 -session=$(dd_current_session) -[ -n "$session" ] || exit 0 -dd_valid_name "$session" || exit 0 - input=$(cat) id=$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null) source=$(jq -r '.source // empty' <<<"$input" 2>/dev/null) [ -n "$id" ] || exit 0 -dd_registry_set_claude "$session" "$id" "$source" 2>/dev/null +# The session's name is read under the registry lock: a rename holds it too, +# so the id can't land under the old name after the entry moved. +# shellcheck disable=SC2317 # called through dd_registry_locked +record() { + local session + session=$(dd_current_session) + if [ -z "$session" ] || ! dd_valid_name "$session"; then return 0; fi + dd_registry_set_claude "$session" "$id" "$source" +} +dd_registry_locked record 2>/dev/null +dd_sweep_tmp exit 0 diff --git a/tests/attach.bats b/tests/attach.bats index 497d52d..f0e8115 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -15,12 +15,14 @@ setup() { export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/no-webhook" # never the real room export FAKE_LOG="$TMP/claude-args" export CLAUDE_BIN="$TMP/fake-claude" + export CLAUDE_CONFIG_DIR="$TMP/claude" # never the real conversation history export PATH="$REPO/bin:$PATH" mkdir -p "$DEVDESK_PROJECTS_ROOT/alpha" "$DEVDESK_PROJECTS_ROOT/test code" "$TMP/outside" "$FAKE_LOG" ln -s "$TMP/outside" "$DEVDESK_PROJECTS_ROOT/escape" cat >"$CLAUDE_BIN" <<'EOF' #!/bin/bash printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION" +[ -z "${FAKE_EXIT:-}" ] || exit "$FAKE_EXIT" exec sleep 300 EOF chmod +x "$CLAUDE_BIN" @@ -440,12 +442,91 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; } @test "session hook records the Claude session and the next start resumes it" { start_server - echo '{"session_id":"abc-123","source":"startup"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook" + id=abcabcab-1234-1234-1234-123412341234 + mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p" && touch "$CLAUDE_CONFIG_DIR/projects/-p/$id.jsonl" + echo "{\"session_id\":\"$id\",\"source\":\"startup\"}" | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook" run jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json" - [ "$output" = "abc-123" ] + [ "$output" = "$id" ] devdesk new alpha run claude_args alpha - [ "$output" = "--resume abc-123 --settings $REPO/share/claude-settings.json" ] + [ "$output" = "--resume $id --settings $REPO/share/claude-settings.json" ] +} + +@test "a conversation whose file is gone starts fresh; a failed resume keeps the id (#12)" { + start_server + id=abcabcab-1234-1234-1234-123412341234 + echo "{\"session_id\":\"$id\"}" | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook" + devdesk new alpha + run claude_args alpha + [ "$output" = "-n alpha --settings $REPO/share/claude-settings.json" ] + tmux -S "$DEVDESK_SOCKET" kill-server + # The file exists, but Claude exits at once (Ctrl+C, network): the menu, not a new conversation. + mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p" && touch "$CLAUDE_CONFIG_DIR/projects/-p/$id.jsonl" + rm -f "$FAKE_LOG/alpha" + FAKE_EXIT=1 start_server + devdesk new alpha + run claude_args alpha + [ "$output" = "--resume $id --settings $REPO/share/claude-settings.json" ] + for _ in $(seq 1 30); do + tmux -S "$DEVDESK_SOCKET" capture-pane -p -t =alpha:claude | grep -q "Claude exited" && break + sleep 0.1 + done + tmux -S "$DEVDESK_SOCKET" capture-pane -p -t =alpha:claude | grep -q "Claude exited" + [ "$(wc -l <"$FAKE_LOG/alpha")" = 1 ] + [ "$(jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = "$id" ] +} + +@test "env files are parsed like systemd does, never run (#12)" { + cat >"$TMP/env" <<'ENV' +# comment +; also a comment +DEVDESK_DEFAULT_DIR=/srv/ops +CLAUDE_ARGS=--model opus --verbose +DEVDESK_PUBLIC_URL="https://dev.example.org" +DEVDESK_GRAFANA_URL='http://g:3000' +DEVDESK_MATRIX_DELAY=$(touch /tmp/devdesk-env-pwned) +lowercase=x +not a line +DEVDESK_BIND="unterminated +ENV + run env DEVDESK_ENV="$TMP/env" DEVDESK_ENV_LOCAL=/dev/null bash -c '. "$1/libexec/devdesk-lib.sh"; printf "%s|" "$DEVDESK_DEFAULT_DIR" "$CLAUDE_ARGS" "$DEVDESK_PUBLIC_URL" "$DEVDESK_GRAFANA_URL" "$DEVDESK_MATRIX_DELAY" "${lowercase:-unset}" "${DEVDESK_BIND:-unset}"' _ "$REPO" + [ ! -e /tmp/devdesk-env-pwned ] + [[ $output == *'/srv/ops|--model opus --verbose|https://dev.example.org|http://g:3000|$(touch /tmp/devdesk-env-pwned)|unset|unset|'* ]] + [[ $output == *"env:8: not a KEY=value line"* ]] + [[ $output == *"env:10: unmatched quote"* ]] +} + +@test "the SessionStart hook reads the session name under the rename lock (#12)" { + start_server + devdesk new alpha + claude_args alpha >/dev/null + pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:claude '#{pane_id}') + # A rename holds the lock; the hook fires meanwhile, while tmux still says "alpha". + ( + flock 9 + sleep 0.5 + tmux -S "$DEVDESK_SOCKET" rename-session -t =alpha beta + DEVDESK_ENV=/dev/null bash -c '. "$1/libexec/devdesk-lib.sh"; DD_REGISTRY_LOCKED=1 dd_registry_rename alpha beta' _ "$REPO" + ) 9>"$DEVDESK_STATE_DIR/registry.json.lock" 3>&- & + renamer=$! + sleep 0.1 + echo '{"session_id":"ffffffff-0000-0000-0000-000000000000","source":"clear"}' | + TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE=$pane "$REPO/libexec/devdesk-session-hook" + wait "$renamer" + [ "$(jq -r '.sessions | keys | join(",")' "$DEVDESK_STATE_DIR/registry.json")" = beta ] + [ "$(jq -r '.sessions.beta.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = ffffffff-0000-0000-0000-000000000000 ] +} + +@test "leftover temp files of killed writers are swept (#12)" { + mkdir -p "$DEVDESK_STATE_DIR/state" "$DEVDESK_STATE_DIR/context" + touch -d '20 minutes ago' "$DEVDESK_STATE_DIR/state/.alpha.Ab12Cd" "$DEVDESK_STATE_DIR/context/.alpha.Xy34Zw" "$DEVDESK_STATE_DIR/registry.json.Qq11Rr" + touch "$DEVDESK_STATE_DIR/state/.beta.Ab12Cd" "$DEVDESK_STATE_DIR/state/alpha.json" + echo '{"session_id":"x"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook" + [ ! -e "$DEVDESK_STATE_DIR/state/.alpha.Ab12Cd" ] + [ ! -e "$DEVDESK_STATE_DIR/context/.alpha.Xy34Zw" ] + [ ! -e "$DEVDESK_STATE_DIR/registry.json.Qq11Rr" ] + [ -e "$DEVDESK_STATE_DIR/state/.beta.Ab12Cd" ] + [ -e "$DEVDESK_STATE_DIR/state/alpha.json" ] } @test "session hook ignores sessions it does not own" {