Add persistent service jobs (-s flag)
Some checks failed
CI / shellcheck (push) Failing after 19s

Introduces a new job type for long-running systemd user services alongside
the existing timer-based jobs. Services use Type=simple with Restart=on-failure
and WantedBy=default.target — no .timer unit is created.

- New -s flag creates a service job; mutually exclusive with -t/-i/-m/-o
- Service jobs tagged with # SYSTAB_TYPE=service in their unit file
- enable/disable (-E/-D) start/stop the service in addition to toggling
  the enabled state, mirroring timer behaviour
- -S status shows ActiveState/SubState from systemd directly (avoids
  false "Inactive" for services in activating state)
- -L logs, -e edit mode, -D/-E disable/enable all handle service jobs
- Edit mode represents service jobs with 'service' as the schedule column
  (e.g. new:s,n=name | service | /path/to/cmd)
- daemon-reload runs before enable/start during service creation so
  systemd registers the new unit file first
- 22 new tests covering unit file contents, active state, disable/enable,
  named services, edit mode representation, and flag conflict errors
- New demo/services.tape and regenerated demo GIFs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Johnson 2026-02-24 01:00:07 -07:00
parent 2b75547726
commit 164087ab22
8 changed files with 514 additions and 109 deletions

82
test.sh
View file

@ -97,10 +97,10 @@ assert_file_contains() {
# Track test-created job IDs for targeted cleanup
test_job_ids=()
# Extract job ID from "Job created: <id>" or "Job created: <id> (<name>)" output
# Extract job ID from "Job created: <id>", "Service created: <id>", or with name suffix
# Sets _extracted_id and appends to test_job_ids for cleanup tracking
extract_id() {
_extracted_id=$(sed -n 's/^Job created: \([0-9a-f]\{6\}\)\( .*\)\{0,1\}$/\1/p' <<< "$_last_output")
_extracted_id=$(sed -n 's/^\(Job\|Service\) created: \([0-9a-f]\{6\}\)\( .*\)\{0,1\}$/\2/p' <<< "$_last_output")
test_job_ids+=("$_extracted_id")
}
@ -333,6 +333,84 @@ else
pass "-o without -i/-m has no ExecStopPost"
fi
# ============================================================
# Services (-s)
# ============================================================
echo ""
echo "${BOLD}--- Services ---${RESET}"
# Create a persistent service job (sleep 3600 stays running)
assert_output "create service job" "Service created:" $SYSTAB -s -c "sleep 3600"
extract_id; id_svc=$_extracted_id
if [[ -z "$id_svc" ]]; then
echo "FATAL: could not extract service job ID, aborting"
exit 1
fi
# Unit file checks (mirrors tape: cat the service file)
assert_file_contains "service file has SYSTAB_TYPE=service" \
"$SYSTEMD_USER_DIR/systab_${id_svc}.service" "^# SYSTAB_TYPE=service$"
assert_file_contains "service file has Type=simple" \
"$SYSTEMD_USER_DIR/systab_${id_svc}.service" "^Type=simple$"
assert_file_contains "service file has Restart=on-failure" \
"$SYSTEMD_USER_DIR/systab_${id_svc}.service" "^Restart=on-failure$"
assert_file_contains "service file has WantedBy=default.target" \
"$SYSTEMD_USER_DIR/systab_${id_svc}.service" "^WantedBy=default.target$"
# No timer file should exist (mirrors tape: no timer)
if [[ -f "$SYSTEMD_USER_DIR/systab_${id_svc}.timer" ]]; then
fail "service job has no .timer file" "timer file unexpectedly exists"
else
pass "service job has no .timer file"
fi
# Service should be active (mirrors tape: "Active (running)")
if systemctl --user is-active "systab_${id_svc}.service" &>/dev/null; then
pass "service job is active"
else
fail "service job is active" "service not running"
fi
# Status shows Type: Service and real systemd state (mirrors tape: systab -S monitor)
assert_output "status shows Type: Service" "Type: Service" $SYSTAB -S "$id_svc"
assert_output "status shows active state" "Service: active" $SYSTAB -S "$id_svc"
# Logs work for service jobs (mirrors tape: systab -L monitor)
assert_output "logs for service job" "Logs for" $SYSTAB -L "$id_svc"
# Service with a name (mirrors tape: -s -n monitor)
assert_output "create service job with name" "Service created:" $SYSTAB -s -n svctest -c "sleep 3600"
extract_id; id_svc_named=$_extracted_id
assert_last_output_contains "service name appears in creation output" "(svctest)"
assert_file_contains "service file has SYSTAB_NAME" \
"$SYSTEMD_USER_DIR/systab_${id_svc_named}.service" "^# SYSTAB_NAME=svctest$"
# Disable stops the service (mirrors tape: systab -D monitor)
assert_output "disable service job" "Disabled:" $SYSTAB -D "$id_svc"
assert_output "disabled service shows in status" "Disabled" $SYSTAB -S "$id_svc"
assert_output "disable already disabled service" "Already disabled:" $SYSTAB -D "$id_svc"
# Enable restarts the service (mirrors tape: systab -E monitor)
assert_output "enable service job" "Enabled:" $SYSTAB -E "$id_svc"
assert_output "enable already enabled service" "Already enabled:" $SYSTAB -E "$id_svc"
# Edit mode shows service jobs with 'service' in schedule column
# (mirrors tape: EDITOR=nano systab -e shows "id:s | service | cmd")
edit_output=$(EDITOR=cat $SYSTAB -e 2>&1 || true)
if [[ "$edit_output" == *"| service |"* ]]; then
pass "edit mode shows service job with 'service' schedule"
else
fail "edit mode shows service job with 'service' schedule" "not found in: $edit_output"
fi
# Mutually exclusive flags (mirrors tape design: -s conflicts with -t/-i/-m/-o)
assert_failure "-s and -t are mutually exclusive" $SYSTAB -s -t daily -c "echo test"
assert_failure "-s and -i are mutually exclusive" $SYSTAB -s -i -c "echo test"
assert_failure "-s and -m are mutually exclusive" $SYSTAB -s -m user@example.com -c "echo test"
assert_failure "-s and -o are mutually exclusive" $SYSTAB -s -o -c "echo test"
# ============================================================
# Clean
# ============================================================