feat: party log records a group-readable transcript
tmux pipe-pane on every pane of the party session, appending into <party dir>/log (0640, party group) so any attendee can grab a copy. Start and stop are announced to everyone attached; close rescues a non-empty transcript to the host's home before removing the dir. Panes opened later are picked up by re-running party log (a pane_pipe check keeps already-piped panes single-piped, since tmux's pipe-pane -o toggles an existing pipe closed rather than skipping it). Raw output, escapes included. Close polls the transcript for size stability before rescuing it, since a cross-filesystem mv is copy+unlink and could otherwise drop bytes still draining from the pipe-pane writers.
This commit is contained in:
parent
a011e4b03c
commit
aea7ef58b6
5 changed files with 229 additions and 7 deletions
|
|
@ -100,6 +100,7 @@ Three honest caveats, with the full detail in `man party`:
|
||||||
- On ACL-enabled filesystems (ZFS, HFS+/APFS), inherited ACLs can override the mode bits, so the FS gate is best-effort. The auth gate still holds.
|
- On ACL-enabled filesystems (ZFS, HFS+/APFS), inherited ACLs can override the mode bits, so the FS gate is best-effort. The auth gate still holds.
|
||||||
- A party's *existence* is not hidden the way *attaching* is. That confidentiality rides on the FS gate.
|
- A party's *existence* is not hidden the way *attaching* is. That confidentiality rides on the FS gate.
|
||||||
- Active guests share one tmux server, where any write-capable invitee is trusted by design. Invite read-only (`-r`) if you do not trust that far.
|
- Active guests share one tmux server, where any write-capable invitee is trusted by design. Invite read-only (`-r`) if you do not trust that far.
|
||||||
|
- `party log`'s transcript is group-readable, not invitee-readable: any member of the party's group can read it, including group members who were never invited.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
|
@ -120,6 +121,7 @@ Three honest caveats, with the full detail in `man party`:
|
||||||
| `party status` | Show the caller's own state: hosting, attached, or idle. |
|
| `party status` | Show the caller's own state: hosting, attached, or idle. |
|
||||||
| `party close` | Tear down the party server and its roster entry. Host-only. |
|
| `party close` | Tear down the party server and its roster entry. Host-only. |
|
||||||
| `party clean` | Remove the caller's own dead party dirs: crash leftovers whose tmux server is gone. |
|
| `party clean` | Remove the caller's own dead party dirs: crash leftovers whose tmux server is gone. |
|
||||||
|
| `party log [--stop]` | Record the party into its private dir via `tmux pipe-pane` (host only), group-readable. `--stop` ends it. |
|
||||||
| `party join [name] [--passive]` | Join a party. Auto-attaches when one is running; picker otherwise. `--passive` attaches read-only to the host's view (watcher mode). Read-only invitees always join passive. |
|
| `party join [name] [--passive]` | Join a party. Auto-attaches when one is running; picker otherwise. `--passive` attaches read-only to the host's view (watcher mode). Read-only invitees always join passive. |
|
||||||
| `party leave` | Detach and clean up the per-guest session. |
|
| `party leave` | Detach and clean up the per-guest session. |
|
||||||
| `party knock <name>` | Ask the host of an invite-only party for an invite (write(1) ping with the exact invite command). |
|
| `party knock <name>` | Ask the host of an invite-only party for an invite (write(1) ping with the exact invite command). |
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,6 @@ Direction, not promises. Two rules govern everything below (README
|
||||||
encouraging people to work together in a terminal, not by adding
|
encouraging people to work together in a terminal, not by adding
|
||||||
machinery.
|
machinery.
|
||||||
|
|
||||||
## Next: small, high value
|
|
||||||
|
|
||||||
- **`party log`:** per-party transcript via `tmux pipe-pane` into the
|
|
||||||
per-party dir (group-readable so every attendee can grab a copy);
|
|
||||||
`party log --stop` ends it. "What did we do last night?" for
|
|
||||||
collectives, and the audit trail human+AI co-work needs.
|
|
||||||
|
|
||||||
## Later
|
## Later
|
||||||
|
|
||||||
- **Matrix:** live validation on NetBSD and DragonFly; one
|
- **Matrix:** live validation on NetBSD and DragonFly; one
|
||||||
|
|
|
||||||
109
party
109
party
|
|
@ -50,6 +50,8 @@ Hosting:
|
||||||
close [name] Tear down the party you host (name it when
|
close [name] Tear down the party you host (name it when
|
||||||
you host several).
|
you host several).
|
||||||
clean Remove your own dead party dirs (crash leftovers).
|
clean Remove your own dead party dirs (crash leftovers).
|
||||||
|
log [--stop] [--party <name>]
|
||||||
|
Record the party into its private dir (host only).
|
||||||
|
|
||||||
Joining:
|
Joining:
|
||||||
join [name] [--passive] Join a party. Without name: auto-pick or numbered prompt.
|
join [name] [--passive] Join a party. Without name: auto-pick or numbered prompt.
|
||||||
|
|
@ -944,6 +946,31 @@ cmd_close() {
|
||||||
# PARTY_SOCKET_DIR is caller-controlled env, not a privilege
|
# PARTY_SOCKET_DIR is caller-controlled env, not a privilege
|
||||||
# boundary, so we don't canonicalize it.
|
# boundary, so we don't canonicalize it.
|
||||||
"$PARTY_TMUX" -S "$expected_sock" kill-server 2>/dev/null || true
|
"$PARTY_TMUX" -S "$expected_sock" kill-server 2>/dev/null || true
|
||||||
|
|
||||||
|
# Rescue a transcript before the dir vanishes: losing the night's
|
||||||
|
# log to a routine close is exactly the accident class the threat
|
||||||
|
# model says to prevent. After kill-server the pipe-pane `cat`
|
||||||
|
# children are exiting but may still be draining their last bytes,
|
||||||
|
# so poll for two equal size samples (bounded, integer sleeps only)
|
||||||
|
# before moving it. A same-filesystem mv is a rename and would keep
|
||||||
|
# catching late writes regardless, but $HOME and PARTY_SOCKET_DIR
|
||||||
|
# are commonly different filesystems (tmpfs /tmp vs a home
|
||||||
|
# partition), where mv is copy+unlink and tail bytes written after
|
||||||
|
# the copy started would be lost from the copy.
|
||||||
|
if [ -s "$expected_dir/log" ]; then
|
||||||
|
_cl_prev=-1
|
||||||
|
for _cl_i in 1 2 3; do
|
||||||
|
_cl_size=$(wc -c < "$expected_dir/log")
|
||||||
|
[ "$_cl_size" -eq "$_cl_prev" ] && break
|
||||||
|
_cl_prev="$_cl_size"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
_cl_dest="$HOME/party-$name-$(date -u '+%Y%m%d-%H%M%S').log"
|
||||||
|
if mv "$expected_dir/log" "$_cl_dest" 2>/dev/null; then
|
||||||
|
echo "party close: transcript saved to $_cl_dest"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
rm -rf "$expected_dir"
|
rm -rf "$expected_dir"
|
||||||
echo "Party '$name' closed."
|
echo "Party '$name' closed."
|
||||||
}
|
}
|
||||||
|
|
@ -984,6 +1011,87 @@ EOF
|
||||||
done
|
done
|
||||||
[ "$removed" -gt 0 ] || echo "nothing to clean."
|
[ "$removed" -gt 0 ] || echo "nothing to clean."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Tell everyone in the room when recording starts or stops: a
|
||||||
|
# transcript nobody knew about is the kind of surprise the social
|
||||||
|
# design exists to avoid. Same fan-out shape as the .party-notify
|
||||||
|
# helper, run inline because only the host (who is authorized by
|
||||||
|
# definition) calls it.
|
||||||
|
log_notify() {
|
||||||
|
"$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_name}' 2>/dev/null \
|
||||||
|
| while read -r _ln_c; do
|
||||||
|
"$PARTY_TMUX" -S "$RR_SOCKET" display-message -c "$_ln_c" \
|
||||||
|
"$USER $1 logging this party" 2>/dev/null || :
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_log() {
|
||||||
|
stop=0 partyname=''
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--stop) stop=1; shift ;;
|
||||||
|
--party)
|
||||||
|
[ $# -ge 2 ] || { echo "party log: --party needs an argument" >&2; exit 2; }
|
||||||
|
partyname="$2"; shift 2 ;;
|
||||||
|
-h|--help)
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: party log [--stop] [--party <name>]
|
||||||
|
Record the party (host only) into <party dir>/log, readable by every
|
||||||
|
group member. Raw pane output, ANSI escapes included (read with
|
||||||
|
less -R). Panes opened after logging starts are not captured; re-run
|
||||||
|
`party log` to pick them up. Starting and stopping is announced to
|
||||||
|
everyone attached. `party close` rescues a non-empty transcript to
|
||||||
|
your home directory before removing the party dir.
|
||||||
|
EOF
|
||||||
|
exit 0 ;;
|
||||||
|
-*) echo "party log: unknown flag '$1'" >&2; exit 2 ;;
|
||||||
|
*)
|
||||||
|
if [ -z "$partyname" ]; then partyname="$1"; shift
|
||||||
|
else echo "party log: unexpected arg '$1'" >&2; exit 2; fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
resolve_authoritative_party "$partyname" || exit 1
|
||||||
|
logfile="${RR_SOCKET%/sock}/log"
|
||||||
|
|
||||||
|
if [ "$stop" = 1 ]; then
|
||||||
|
# pipe-pane with no command closes a pane's pipe; harmless on
|
||||||
|
# panes that were never piped.
|
||||||
|
"$PARTY_TMUX" -S "$RR_SOCKET" list-panes -s -t "$RR_PARTY_NAME" -F '#{pane_id}' \
|
||||||
|
| while read -r _lg_p; do
|
||||||
|
"$PARTY_TMUX" -S "$RR_SOCKET" pipe-pane -t "$_lg_p"
|
||||||
|
done
|
||||||
|
log_notify stopped
|
||||||
|
echo "Logging stopped. Transcript: $logfile"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create the file ahead of pipe-pane with the roster's perms
|
||||||
|
# recipe (0640 + party group): every group member can grab a copy,
|
||||||
|
# only the host writes. Append so resuming mid-party keeps earlier
|
||||||
|
# segments.
|
||||||
|
: >> "$logfile"
|
||||||
|
chmod 0640 "$logfile"
|
||||||
|
chgrp "${RR_GROUP:-$TMUX_PARTY_GROUP}" "$logfile"
|
||||||
|
|
||||||
|
# Filter to unpiped panes ourselves rather than trusting `-o` to
|
||||||
|
# skip them: on tmux 3.5a, `-o` toggles an existing pipe closed
|
||||||
|
# instead of leaving it alone, which would silently stop logging
|
||||||
|
# every previously-piped pane on a second `party log` call. Reading
|
||||||
|
# #{pane_pipe} first keeps re-running `party log` idempotent for
|
||||||
|
# already-piped panes while still attaching new ones. The path is
|
||||||
|
# single-quoted for the server-side sh: a quote in PARTY_SOCKET_DIR
|
||||||
|
# is accepted breakage, same as the status-right widget.
|
||||||
|
"$PARTY_TMUX" -S "$RR_SOCKET" list-panes -s -t "$RR_PARTY_NAME" -F '#{pane_pipe} #{pane_id}' \
|
||||||
|
| while read -r _lg_piped _lg_p; do
|
||||||
|
[ "$_lg_piped" = 1 ] && continue
|
||||||
|
"$PARTY_TMUX" -S "$RR_SOCKET" pipe-pane -t "$_lg_p" "cat >> '$logfile'"
|
||||||
|
done
|
||||||
|
log_notify started
|
||||||
|
echo "Logging to $logfile"
|
||||||
|
}
|
||||||
|
|
||||||
cmd_join() {
|
cmd_join() {
|
||||||
passive=0
|
passive=0
|
||||||
name=
|
name=
|
||||||
|
|
@ -1586,6 +1694,7 @@ dispatch() {
|
||||||
host) cmd_host "$@" ;;
|
host) cmd_host "$@" ;;
|
||||||
close) cmd_close "$@" ;;
|
close) cmd_close "$@" ;;
|
||||||
clean) cmd_clean "$@" ;;
|
clean) cmd_clean "$@" ;;
|
||||||
|
log) cmd_log "$@" ;;
|
||||||
join) cmd_join "$@" ;;
|
join) cmd_join "$@" ;;
|
||||||
leave) cmd_leave "$@" ;;
|
leave) cmd_leave "$@" ;;
|
||||||
knock) cmd_knock "$@" ;;
|
knock) cmd_knock "$@" ;;
|
||||||
|
|
|
||||||
31
party.1
31
party.1
|
|
@ -16,6 +16,10 @@
|
||||||
.Nm
|
.Nm
|
||||||
.Cm clean
|
.Cm clean
|
||||||
.Nm
|
.Nm
|
||||||
|
.Cm log
|
||||||
|
.Op Fl -stop
|
||||||
|
.Op Fl -party Ar name
|
||||||
|
.Nm
|
||||||
.Cm join
|
.Cm join
|
||||||
.Op Ar name
|
.Op Ar name
|
||||||
.Op Fl -passive
|
.Op Fl -passive
|
||||||
|
|
@ -142,6 +146,23 @@ Host-only.
|
||||||
Remove the caller's own dead party directories: crash leftovers whose
|
Remove the caller's own dead party directories: crash leftovers whose
|
||||||
tmux server no longer answers.
|
tmux server no longer answers.
|
||||||
Never touches live parties, other users' directories, or symlinks.
|
Never touches live parties, other users' directories, or symlinks.
|
||||||
|
.It Cm log Op Fl -stop Op Fl -party Ar name
|
||||||
|
Record the party into
|
||||||
|
.Pa log
|
||||||
|
inside the per-party directory (host only), via
|
||||||
|
.Xr tmux 1
|
||||||
|
.Ic pipe-pane .
|
||||||
|
The file is group-readable so any attendee can grab a copy.
|
||||||
|
Starting and stopping is announced to everyone attached.
|
||||||
|
Panes opened after logging starts are not captured until
|
||||||
|
.Cm log
|
||||||
|
is re-run; output is raw (ANSI escapes included, read with
|
||||||
|
.Ql less -R ) .
|
||||||
|
.Fl -stop
|
||||||
|
ends recording.
|
||||||
|
.Cm close
|
||||||
|
rescues a non-empty transcript to the host's home directory before
|
||||||
|
removing the party directory.
|
||||||
.It Cm join Op Ar name Op Fl -passive
|
.It Cm join Op Ar name Op Fl -passive
|
||||||
Join a party.
|
Join a party.
|
||||||
With no
|
With no
|
||||||
|
|
@ -314,6 +335,11 @@ every read, not trusted from the file.
|
||||||
Auto-generated helper that fans
|
Auto-generated helper that fans
|
||||||
.Cm display-message
|
.Cm display-message
|
||||||
out to every attached client on join/leave.
|
out to every attached client on join/leave.
|
||||||
|
.It Pa ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/log
|
||||||
|
Transcript written by
|
||||||
|
.Cm log ;
|
||||||
|
removed with the party directory unless rescued by
|
||||||
|
.Cm close .
|
||||||
.El
|
.El
|
||||||
.Sh EXIT STATUS
|
.Sh EXIT STATUS
|
||||||
.Bl -tag -width Ds
|
.Bl -tag -width Ds
|
||||||
|
|
@ -493,6 +519,11 @@ has no equivalent of
|
||||||
The watcher boundary is set at
|
The watcher boundary is set at
|
||||||
.Cm join Fl -passive
|
.Cm join Fl -passive
|
||||||
time and is fixed for the life of that client.
|
time and is fixed for the life of that client.
|
||||||
|
.Pp
|
||||||
|
Every member of the party's group can read a
|
||||||
|
.Cm log
|
||||||
|
transcript, since the per-party directory is the perimeter, including
|
||||||
|
group members who were never invited.
|
||||||
.Sh SEE ALSO
|
.Sh SEE ALSO
|
||||||
.Xr tmux 1 ,
|
.Xr tmux 1 ,
|
||||||
.Xr screen 1 ,
|
.Xr screen 1 ,
|
||||||
|
|
|
||||||
87
tests/87-log.bats
Normal file
87
tests/87-log.bats
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
#!/usr/bin/env bats
|
||||||
|
#
|
||||||
|
# party log: pipe-pane transcript into the per-party dir. Needs a real
|
||||||
|
# tmux server (pipe-pane semantics can't be stubbed honestly), so most
|
||||||
|
# tests gate on require_party_group like the other e2e suites.
|
||||||
|
|
||||||
|
load 'helpers'
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
setup_party_sandbox
|
||||||
|
export TMUX_PARTY_GROUP="$(id -gn)"
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown() { teardown_party_sandbox; }
|
||||||
|
|
||||||
|
logfile() { printf '%s/party-%s:%s.d/log\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; }
|
||||||
|
sock_of() { printf '%s/party-%s:%s.d/sock\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; }
|
||||||
|
|
||||||
|
# Wait up to ~10s for $2 to appear in file $1. Integer sleeps only:
|
||||||
|
# fractional sleep is not portable across the matrix.
|
||||||
|
poll_for() {
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
grep -q "$2" "$1" 2>/dev/null && return 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "log without a hosted party fails" {
|
||||||
|
run "$PARTY_BIN" log
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"do not host"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "log captures pane output; --stop ends capture" {
|
||||||
|
"$PARTY_BIN" host scribe
|
||||||
|
run "$PARTY_BIN" log
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"Logging to"* ]]
|
||||||
|
|
||||||
|
tmux -S "$(sock_of scribe)" send-keys -t scribe 'echo marker-ONE' Enter
|
||||||
|
poll_for "$(logfile scribe)" marker-ONE
|
||||||
|
|
||||||
|
run "$PARTY_BIN" log --stop
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
tmux -S "$(sock_of scribe)" send-keys -t scribe 'echo marker-TWO' Enter
|
||||||
|
sleep 2
|
||||||
|
! grep -q marker-TWO "$(logfile scribe)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "log file is 0640 so group members can copy it" {
|
||||||
|
"$PARTY_BIN" host perms
|
||||||
|
"$PARTY_BIN" log
|
||||||
|
[ -n "$(find "$(logfile perms)" -prune -perm 0640 2>/dev/null)" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "re-running log attaches panes created after the first run" {
|
||||||
|
"$PARTY_BIN" host panes
|
||||||
|
"$PARTY_BIN" log
|
||||||
|
tmux -S "$(sock_of panes)" split-window -t panes
|
||||||
|
"$PARTY_BIN" log
|
||||||
|
tmux -S "$(sock_of panes)" send-keys -t panes.1 'echo marker-PANE2' Enter
|
||||||
|
poll_for "$(logfile panes)" marker-PANE2
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "close rescues a non-empty transcript to HOME" {
|
||||||
|
export HOME="$PARTY_TMP/home"
|
||||||
|
mkdir -p "$HOME"
|
||||||
|
"$PARTY_BIN" host rescue
|
||||||
|
"$PARTY_BIN" log
|
||||||
|
tmux -S "$(sock_of rescue)" send-keys -t rescue 'echo marker-KEEP' Enter
|
||||||
|
poll_for "$(logfile rescue)" marker-KEEP
|
||||||
|
run "$PARTY_BIN" close rescue
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"transcript saved to"* ]]
|
||||||
|
saved=$(ls "$HOME"/party-rescue-*.log)
|
||||||
|
grep -q marker-KEEP "$saved"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "close without a transcript stays silent about rescue" {
|
||||||
|
export HOME="$PARTY_TMP/home"
|
||||||
|
mkdir -p "$HOME"
|
||||||
|
"$PARTY_BIN" host quiet
|
||||||
|
run "$PARTY_BIN" close quiet
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" != *"transcript"* ]]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue