fix: v0.9.1 robustness cleanups
build-deb / Lint, test, package (push) Successful in 3m53s
build-deb / Deploy to CT 126 (push) Successful in 10s
build-deb / Notify on failure (push) Skipped

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 01:01:51 -04:00
co-authored by Claude Opus 5.5
parent 73e77aca73
commit 74927a7640
14 changed files with 277 additions and 68 deletions
+7 -2
View File
@@ -157,6 +157,8 @@ jobs:
steps: steps:
# CT 126's webhook listener runs /usr/lib/devdesk/devdesk-deploy, which # CT 126's webhook listener runs /usr/lib/devdesk/devdesk-deploy, which
# waits for this version in the apt registry and upgrades the package. # 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 - name: Trigger devdesk-deploy webhook
env: env:
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }} WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
@@ -164,12 +166,15 @@ jobs:
run: | run: |
PAYLOAD="{\"ref\":\"${GIT_REF}\"}" PAYLOAD="{\"ref\":\"${GIT_REF}\"}"
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}') 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 \ -X POST \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-Gitea-Signature: ${SIG}" \ -H "X-Gitea-Signature: ${SIG}" \
-d "$PAYLOAD" \ -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: notify-failure:
name: Notify on failure name: Notify on failure
+10 -4
View File
@@ -141,11 +141,17 @@ cmd_rename() {
if dd_server_up && dd_tmux has-session -t "=$old" 2>/dev/null; then running=1; fi 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'" [ "$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" ! session_exists "$new" || dd_die "a session named '$new' already exists"
if [ "$running" = 1 ]; then dd_registry_locked rename_locked "$old" "$new" "$running" || dd_die "could not rename '$old'"
dd_tmux rename-session -t "=$old" "$new" }
dd_tmux set-environment -t "=$new" DEVDESK_SESSION "$new"
# 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 fi
dd_registry_rename "$old" "$new" dd_registry_rename "$1" "$2"
} }
cmd_end() { cmd_end() {
+25
View File
@@ -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 <jjvititoe1@gmail.com> Sun, 11 Oct 2026 01:01:33 -0400
devdesk (0.9.0) bookworm; urgency=medium devdesk (0.9.0) bookworm; urgency=medium
* Search past conversations (#24): every Claude conversation on this host * Search past conversations (#24): every Claude conversation on this host
+8
View File
@@ -0,0 +1,8 @@
/var/log/devdesk-deploy.log {
monthly
rotate 6
compress
delaycompress
missingok
notifempty
}
+7
View File
@@ -13,6 +13,13 @@ configure)
# Apply tmux.conf changes to a running server without restarting it. # Apply tmux.conf changes to a running server without restarting it.
if [ -S /run/devdesk/tmux.sock ]; then 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 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 fi
;; ;;
esac esac
+5 -1
View File
@@ -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. - **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 ## 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 <ref>`, 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 <ref>`, 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:** **Setup, done once:**
1. Generate a secret. 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", "execute-command": "/usr/lib/devdesk/devdesk-deploy",
"command-working-directory": "/", "command-working-directory": "/",
"pass-arguments-to-command": [{"source": "payload", "name": "ref"}], "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": [ "trigger-rule": {"and": [
{"match": {"type": "payload-hash-sha256", "secret": "<WEBHOOK_SECRET>", {"match": {"type": "payload-hash-sha256", "secret": "<WEBHOOK_SECRET>",
"parameter": {"source": "header", "name": "X-Gitea-Signature"}}}, "parameter": {"source": "header", "name": "X-Gitea-Signature"}}},
+3 -2
View File
@@ -1,8 +1,9 @@
# DevDesk defaults, shipped by the package. Don't edit: put your changes in # 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 # /etc/devdesk/env.local (same KEY=value format, read after this file), so
# package upgrades never prompt about this file. # package upgrades never prompt about this file.
# Read by systemd (EnvironmentFile=) and by the devdesk scripts. Plain KEY=value # Read by systemd (EnvironmentFile=) and by the devdesk scripts, the same way:
# lines only: systemd does not support inline comments. # 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). # The ttyd credential lives in /etc/devdesk/secret.env (generated on install).
# Port ttyd listens on (NPM proxies dev.lotusguild.org here). # Port ttyd listens on (NPM proxies dev.lotusguild.org here).
+10 -11
View File
@@ -42,12 +42,21 @@ run_new() {
} }
run_resume() { run_resume() {
local id args started rc fork=() local id args fork=()
id=$(dd_registry_get "$NAME" claude_session_id) id=$(dd_registry_get "$NAME" claude_session_id)
if [ -z "$id" ]; then if [ -z "$id" ]; then
run_new run_new
return return
fi 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) mapfile -d '' args < <(common_args)
# "Fork" from conversation search (devdesk#24): continue as a new # "Fork" from conversation search (devdesk#24): continue as a new
# conversation, once; the SessionStart hook then records the new id. # conversation, once; the SessionStart hook then records the new id.
@@ -56,17 +65,7 @@ run_resume() {
# shellcheck disable=SC2016 # $n is a jq variable # shellcheck disable=SC2016 # $n is a jq variable
dd_registry_update 'del(.sessions[$n].fork)' --arg n "$NAME" 2>/dev/null || true dd_registry_update 'del(.sessions[$n].fork)' --arg n "$NAME" 2>/dev/null || true
fi fi
started=$SECONDS
"$CLAUDE_BIN" --resume "$id" "${fork[@]}" "${args[@]}" "$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() { menu() {
+32 -9
View File
@@ -3,6 +3,9 @@
# listener (hook "devdesk-deploy") when CI publishes a v* tag; the tag's ref is # 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 # passed as $1. Safe for running sessions: package upgrades restart only
# devdesk.service (ttyd), never devdesk-tmux.service where Claude lives. # 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 set -euo pipefail
LOG=${DEVDESK_DEPLOY_LOG:-/var/log/devdesk-deploy.log} LOG=${DEVDESK_DEPLOY_LOG:-/var/log/devdesk-deploy.log}
@@ -12,11 +15,28 @@ log() {
printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$LOG" printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$LOG"
} }
exec 9>/run/devdesk-deploy.lock # The outcome goes to the log and to stdout (the webhook response).
flock -w 900 9 || { say() {
log "another deploy is still running; giving up" log "$*"
printf '%s\n' "$*"
}
fail() {
say "deploy FAILED: $*"
exit 1 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=${1:-}
want=${want#refs/tags/} 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}" log "=== deploy: installed $before, release ${want:-latest}"
refresh() { 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 -o APT::Get::List-Cleanup=0 >>"$LOG" 2>&1
} }
@@ -41,14 +61,17 @@ for _ in $(seq 1 18); do
done done
if [ -n "$want" ] && [ "$candidate" != "$want" ]; then if [ -n "$want" ] && [ "$candidate" != "$want" ]; then
log "registry still offers $candidate, not $want; giving up" fail "the registry still offers ${candidate:-nothing}, not $want"
exit 1
fi fi
if [ "$candidate" = "$before" ]; then if [ "$candidate" = "$before" ]; then
log "already at $before; nothing to do" say "already at $before; nothing to do"
exit 0 exit 0
fi 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 </dev/null >>"$LOG" 2>&1 -o Dpkg::Options::=--force-confold devdesk </dev/null >>"$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"
-9
View File
@@ -2,15 +2,6 @@
# Past conversations (devdesk#24) and client presence (devdesk#26), sourced by # Past conversations (devdesk#24) and client presence (devdesk#26), sourced by
# bin/devdesk and reached through `devdesk attach --search|--resume-convo|--presence`. # 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 # --search QUERY: full-text search over every Claude conversation on this host
# (DevDesk and code-server alike). Case-insensitive, plain text. Newest first; # (DevDesk and code-server alike). Case-insensitive, plain text. Newest first;
# per conversation: title, folder, dates and up to 3 snippets (what you asked, # per conversation: title, folder, dates and up to 3 snippets (what you asked,
+1 -2
View File
@@ -50,8 +50,7 @@ cmd_answer() {
dd_transcript() { dd_transcript() {
local id local id
id=$(dd_registry_get "$1" claude_session_id) id=$(dd_registry_get "$1" claude_session_id)
[[ $id =~ ^[0-9a-f-]{36}$ ]] || return 1 dd_convo_file "$id"
compgen -G "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/*/$id.jsonl" | head -n1
} }
# --peek SESSION: what the session is doing and the last exchange, from the end # --peek SESSION: what the session is doing and the last exchange, from the end
+74 -20
View File
@@ -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. # 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} 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() { dd_load_env() {
local f local _dd_f _dd_line _dd_n _dd_key _dd_val
for f in "$DEVDESK_ENV" "$DEVDESK_ENV_LOCAL"; do for _dd_f in "$DEVDESK_ENV" "$DEVDESK_ENV_LOCAL"; do
if [ -r "$f" ]; then [ -r "$_dd_f" ] || continue
# shellcheck source=/dev/null _dd_n=0
. "$f" while IFS= read -r _dd_line || [ -n "$_dd_line" ]; do
fi _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 done
} }
dd_load_env 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 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_locked CMD [ARGS...] — run CMD holding the registry lock, so a
dd_registry_update() { # step that reads tmux and then writes the registry (the SessionStart hook,
local filter=$1 # rename) can't interleave with another. Nests: an update inside reuses it.
shift dd_registry_locked() {
if [ -n "${DD_REGISTRY_LOCKED:-}" ]; then
"$@"
return
fi
mkdir -p "$DEVDESK_STATE_DIR" mkdir -p "$DEVDESK_STATE_DIR"
( (
flock -w 5 9 || exit 1 flock -w 5 9 || exit 1
[ -s "$DEVDESK_REGISTRY" ] || printf '{"sessions":{}}\n' >"$DEVDESK_REGISTRY" DD_REGISTRY_LOCKED=1
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
) 9>"$DEVDESK_REGISTRY.lock" ) 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/<folder>/<id>.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): .<session>.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_set_dir() {
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, updated: $t})' \ dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, updated: $t})' \
--arg n "$1" --arg d "$2" --arg t "$(date -Is)" --arg n "$1" --arg d "$2" --arg t "$(date -Is)"
+11 -5
View File
@@ -7,14 +7,20 @@
# shellcheck source=libexec/devdesk-lib.sh # shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" || exit 0 . "${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) input=$(cat)
id=$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null) id=$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null)
source=$(jq -r '.source // empty' <<<"$input" 2>/dev/null) source=$(jq -r '.source // empty' <<<"$input" 2>/dev/null)
[ -n "$id" ] || exit 0 [ -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 exit 0
+84 -3
View File
@@ -15,12 +15,14 @@ setup() {
export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/no-webhook" # never the real room export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/no-webhook" # never the real room
export FAKE_LOG="$TMP/claude-args" export FAKE_LOG="$TMP/claude-args"
export CLAUDE_BIN="$TMP/fake-claude" export CLAUDE_BIN="$TMP/fake-claude"
export CLAUDE_CONFIG_DIR="$TMP/claude" # never the real conversation history
export PATH="$REPO/bin:$PATH" export PATH="$REPO/bin:$PATH"
mkdir -p "$DEVDESK_PROJECTS_ROOT/alpha" "$DEVDESK_PROJECTS_ROOT/test code" "$TMP/outside" "$FAKE_LOG" mkdir -p "$DEVDESK_PROJECTS_ROOT/alpha" "$DEVDESK_PROJECTS_ROOT/test code" "$TMP/outside" "$FAKE_LOG"
ln -s "$TMP/outside" "$DEVDESK_PROJECTS_ROOT/escape" ln -s "$TMP/outside" "$DEVDESK_PROJECTS_ROOT/escape"
cat >"$CLAUDE_BIN" <<'EOF' cat >"$CLAUDE_BIN" <<'EOF'
#!/bin/bash #!/bin/bash
printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION" printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION"
[ -z "${FAKE_EXIT:-}" ] || exit "$FAKE_EXIT"
exec sleep 300 exec sleep 300
EOF EOF
chmod +x "$CLAUDE_BIN" 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" { @test "session hook records the Claude session and the next start resumes it" {
start_server 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" run jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json"
[ "$output" = "abc-123" ] [ "$output" = "$id" ]
devdesk new alpha devdesk new alpha
run claude_args 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" { @test "session hook ignores sessions it does not own" {