From eb99628cd6818b696e23af8664d996bc143d2783 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 18 Jun 2026 10:29:48 +0800 Subject: [PATCH 1/3] dmux --- .dmux-hooks/AGENTS.md | 445 ++++++++++++++++++ .dmux-hooks/CLAUDE.md | 445 ++++++++++++++++++ .dmux-hooks/README.md | 53 +++ .dmux-hooks/examples/post_merge.example | 66 +++ .dmux-hooks/examples/run_dev.example | 62 +++ .dmux-hooks/examples/run_test.example | 61 +++ .dmux-hooks/examples/worktree_created.example | 48 ++ .gitignore | 1 + 8 files changed, 1181 insertions(+) create mode 100644 .dmux-hooks/AGENTS.md create mode 100644 .dmux-hooks/CLAUDE.md create mode 100644 .dmux-hooks/README.md create mode 100755 .dmux-hooks/examples/post_merge.example create mode 100755 .dmux-hooks/examples/run_dev.example create mode 100755 .dmux-hooks/examples/run_test.example create mode 100755 .dmux-hooks/examples/worktree_created.example diff --git a/.dmux-hooks/AGENTS.md b/.dmux-hooks/AGENTS.md new file mode 100644 index 00000000..1a5001bb --- /dev/null +++ b/.dmux-hooks/AGENTS.md @@ -0,0 +1,445 @@ +# dmux Hooks System - Agent Reference + +**Auto-generated documentation for AI agents** + +This document contains everything an AI agent needs to create, modify, and understand dmux hooks. It is automatically generated from the dmux source code and embedded in the binary. + +## What You're Working On + +You are editing hooks for **dmux**, a tmux pane manager that creates AI-powered development workflows. Each pane runs in its own git worktree with an AI agent. + +## Your Goal + +Create executable bash scripts in `.dmux-hooks/` that run automatically at key lifecycle events. + +## Quick Start + +1. **Create a hook file**: `touch .dmux-hooks/worktree_created` +2. **Make it executable**: `chmod +x .dmux-hooks/worktree_created` +3. **Add shebang**: Start with `#!/bin/bash` +4. **Use environment variables**: Access `$DMUX_ROOT`, `$DMUX_WORKTREE_PATH`, etc. +5. **Test it**: Set env vars manually and run the script + +## Hook Execution Model + +- **Mostly non-blocking**: Most hooks run in background (detached processes) +- **Bootstrap-gated**: `worktree_created` blocks agent launch so setup can finish first, with no fixed timeout +- **Live bootstrap output**: During `worktree_created`, stdout/stderr is streamed into the new pane's setup UI +- **Failure behavior**: Background hook errors are logged; gated hooks can abort the operation +- **Environment-based**: All context passed via environment variables +- **Version controlled**: Hooks in `.dmux-hooks/` are shared with team +- **Priority resolution**: `.dmux-hooks/` → `.dmux/hooks/` → `~/.dmux/hooks/` + +## Available Hooks + +### Pane Lifecycle Hooks + +| Hook | When | Common Use Cases | +|------|------|------------------| +| `before_pane_create` | Before pane creation | Validation, notifications, pre-flight checks | +| `pane_created` | After pane, before worktree | Configure tmux settings, prepare environment | +| `worktree_created` | After worktree creation, before agent launch | Install deps, copy configs, setup git | +| `before_pane_close` | Before closing | Save state, backup uncommitted work | +| `pane_closed` | After closed | Cleanup resources, analytics, notifications | + +### Worktree Lifecycle Hooks + +| Hook | When | Common Use Cases | +|------|------|------------------| +| `before_worktree_remove` | Before worktree removal | Archive worktree, save artifacts | +| `worktree_removed` | After worktree removed | Cleanup external references | + +### Merge Lifecycle Hooks + +| Hook | When | Common Use Cases | +|------|------|------------------| +| `pre_merge` | Before merge operation | Run final tests, create backups | +| `post_merge` | After successful merge | Deploy, close issues, notify team | + +### Interactive Hooks (with HTTP callbacks) + +| Hook | When | Common Use Cases | +|------|------|------------------| +| `run_test` | When tests triggered | Run test suite, report status via HTTP | +| `run_dev` | When dev server triggered | Start dev server, create tunnel, report URL | + + +## Environment Variables + +### Always Available +```bash +DMUX_ROOT="/path/to/project" # Project root directory +DMUX_SERVER_PORT="3142" # HTTP server port +``` + +### Pane Context (most hooks) +```bash +DMUX_PANE_ID="dmux-1234567890" # dmux pane identifier +DMUX_SLUG="fix-auth-bug" # Branch/worktree name +DMUX_PROMPT="Fix authentication bug" # User's prompt +DMUX_AGENT="claude" # Agent type (registry id, e.g. claude, codex, opencode) +DMUX_TMUX_PANE_ID="%38" # tmux pane ID +``` + +### Worktree Context +```bash +DMUX_WORKTREE_PATH="/path/.dmux/worktrees/fix-auth-bug" +DMUX_BRANCH="fix-auth-bug" # Same as slug +``` + +### Bootstrap Progress Context +```bash +DMUX_PROGRESS="1" # Set when output is shown in the new pane setup UI +DMUX_STATUS_PREFIX="DMUX_STATUS:" # Prefix for clean status messages +``` + +`worktree_created` can emit progress while it runs. Any stdout/stderr line is shown in the setup UI; lines prefixed with `$DMUX_STATUS_PREFIX` are displayed without the prefix. + +```bash +status() { + if [ "${DMUX_PROGRESS:-0}" = "1" ]; then + echo "${DMUX_STATUS_PREFIX:-DMUX_STATUS:} $*" + else + echo "[Hook] $*" + fi +} +``` + +### Merge Context +```bash +DMUX_TARGET_BRANCH="main" # Branch being merged into +``` + +## HTTP Callback API + +Interactive hooks (`run_test` and `run_dev`) can update dmux UI via HTTP. + +### Update Test Status +```bash +curl -X PUT "http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/test" -H "Content-Type: application/json" -d '{"status": "running", "output": "optional test output"}' + +# Status values: "running" | "passed" | "failed" +``` + +### Update Dev Server +```bash +curl -X PUT "http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/dev" -H "Content-Type: application/json" -d '{"status": "running", "url": "http://localhost:3000"}' + +# Status values: "running" | "stopped" +# url: Can be localhost or tunnel URL (ngrok, cloudflared, etc.) +``` + +## Common Patterns + +### Pattern 1: Install Dependencies +```bash +#!/bin/bash +# .dmux-hooks/worktree_created + +cd "$DMUX_WORKTREE_PATH" + +status() { + if [ "${DMUX_PROGRESS:-0}" = "1" ]; then + echo "${DMUX_STATUS_PREFIX:-DMUX_STATUS:} $*" + else + echo "[Hook] $*" + fi +} + +if [ -f "pnpm-lock.yaml" ]; then + status "Installing dependencies with pnpm" + pnpm install --prefer-offline +elif [ -f "package-lock.json" ]; then + status "Installing dependencies with npm" + npm install +elif [ -f "yarn.lock" ]; then + status "Installing dependencies with yarn" + yarn install +elif [ -f "Gemfile" ]; then + status "Installing gems" + bundle install +elif [ -f "requirements.txt" ]; then + status "Installing Python dependencies" + pip install -r requirements.txt +elif [ -f "Cargo.toml" ]; then + status "Building Rust project" + cargo build +fi +``` + +### Pattern 2: Copy Configuration +```bash +#!/bin/bash +# .dmux-hooks/worktree_created + +# Copy environment file +if [ -f "$DMUX_ROOT/.env.local" ]; then + cp "$DMUX_ROOT/.env.local" "$DMUX_WORKTREE_PATH/.env.local" +fi + +# Copy other config files +for file in .env.development .npmrc .yarnrc; do + if [ -f "$DMUX_ROOT/$file" ]; then + cp "$DMUX_ROOT/$file" "$DMUX_WORKTREE_PATH/$file" + fi +done +``` + +### Pattern 3: Run Tests with Status Updates +```bash +#!/bin/bash +# .dmux-hooks/run_test + +set -e +cd "$DMUX_WORKTREE_PATH" +API="http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/test" + +# Update: starting +curl -s -X PUT "$API" -H "Content-Type: application/json" -d '{"status": "running"}' > /dev/null + +# Run tests and capture output +OUTPUT_FILE="/tmp/dmux-test-$DMUX_PANE_ID.txt" +if pnpm test > "$OUTPUT_FILE" 2>&1; then + STATUS="passed" +else + STATUS="failed" +fi + +# Get output (truncate if too long) +OUTPUT=$(head -c 5000 "$OUTPUT_FILE") + +# Update: complete +curl -s -X PUT "$API" -H "Content-Type: application/json" -d "$(jq -n --arg status "$STATUS" --arg output "$OUTPUT" '{status: $status, output: $output}')" > /dev/null + +rm -f "$OUTPUT_FILE" +``` + +### Pattern 4: Dev Server with Tunnel +```bash +#!/bin/bash +# .dmux-hooks/run_dev + +set -e +cd "$DMUX_WORKTREE_PATH" +API="http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/dev" + +# Start dev server in background +LOG_FILE="/tmp/dmux-dev-$DMUX_PANE_ID.log" +pnpm dev > "$LOG_FILE" 2>&1 & +DEV_PID=$! + +# Wait for server to start +sleep 5 + +# Detect port from logs +PORT=$(grep -oP 'localhost:Kd+' "$LOG_FILE" | head -1) +[ -z "$PORT" ] && PORT=3000 + +# Optional: Create tunnel with cloudflared +if command -v cloudflared &> /dev/null; then + TUNNEL=$(cloudflared tunnel --url "http://localhost:$PORT" 2>&1 | grep -oP 'https://[a-z0-9-]+.trycloudflare.com' | head -1) + URL="${TUNNEL:-http://localhost:$PORT}" +else + URL="http://localhost:$PORT" +fi + +# Report status +curl -s -X PUT "$API" -H "Content-Type: application/json" -d "{"status": "running", "url": "$URL"}" > /dev/null + +echo "[Hook] Dev server running at $URL (PID: $DEV_PID)" +``` + +### Pattern 5: Post-Merge Deployment +```bash +#!/bin/bash +# .dmux-hooks/post_merge + +set -e +cd "$DMUX_ROOT" + +# Only deploy from main/master +if [ "$DMUX_TARGET_BRANCH" != "main" ] && [ "$DMUX_TARGET_BRANCH" != "master" ]; then + exit 0 +fi + +# Push to remote +git push origin "$DMUX_TARGET_BRANCH" + +# Trigger deployment (example: Vercel) +if [ -n "$VERCEL_TOKEN" ]; then + curl -s -X POST "https://api.vercel.com/v1/deployments" -H "Authorization: Bearer $VERCEL_TOKEN" -H "Content-Type: application/json" -d '{"name": "my-project"}' > /dev/null +fi + +# Close GitHub issue if prompt contains #123 +ISSUE=$(echo "$DMUX_PROMPT" | grep -oP '#Kd+' | head -1) +if [ -n "$ISSUE" ] && command -v gh &> /dev/null; then + gh issue close "$ISSUE" -c "Resolved in $DMUX_SLUG, merged to $DMUX_TARGET_BRANCH" 2>/dev/null || true +fi +``` + +## Best Practices + +1. **Always start with shebang**: `#!/bin/bash` +2. **Set error handling**: `set -e` (exit on error) +3. **Make executable**: `chmod +x .dmux-hooks/hook_name` +4. **Background long operations**: Append `&` to avoid blocking +5. **Check for required tools**: `command -v tool &> /dev/null` +6. **Log for debugging**: `echo "[Hook] message" >> "$DMUX_ROOT/.dmux/hooks.log"` +7. **Handle missing vars gracefully**: `[ -z "$VAR" ] && exit 0` +8. **Use silent curl**: `curl -s` to avoid noise in logs +9. **Clean up temp files**: Remove files in `/tmp/` +10. **Test before committing**: Run hooks manually with mock env vars + +## Testing Hooks + +### Manual Testing +```bash +# 1. Set environment variables +export DMUX_ROOT="$(pwd)" +export DMUX_PANE_ID="test-pane" +export DMUX_SLUG="test-branch" +export DMUX_WORKTREE_PATH="$(pwd)" +export DMUX_SERVER_PORT="3142" +export DMUX_AGENT="claude" +export DMUX_PROMPT="Test prompt" + +# 2. Run hook directly +./.dmux-hooks/worktree_created + +# 3. Check exit code +echo $? # Should be 0 for success +``` + +### Syntax Check +```bash +# Check for syntax errors without running +bash -n ./.dmux-hooks/worktree_created +``` + +### Shellcheck (if available) +```bash +shellcheck ./.dmux-hooks/worktree_created +``` + +## Project Context Analysis + +Before creating hooks, analyze these files in the project: + +### Package Manager Detection +```bash +# Check which package manager is used +if [ -f "pnpm-lock.yaml" ]; then + # Use: pnpm install, pnpm test, pnpm dev +elif [ -f "package-lock.json" ]; then + # Use: npm install, npm test, npm run dev +elif [ -f "yarn.lock" ]; then + # Use: yarn install, yarn test, yarn dev +fi +``` + +### Test Command Discovery +```bash +# Read package.json to find test command +cat package.json | grep '"test"' +# Or with jq: +jq -r '.scripts.test' package.json +``` + +### Dev Command Discovery +```bash +# Read package.json to find dev command +cat package.json | grep '"dev"' +# Or with jq: +jq -r '.scripts.dev' package.json +``` + +### Environment Variables +```bash +# Check for .env files to copy +ls -la | grep '.env' +``` + +### Build System +```bash +# Detect build system +if [ -f "vite.config.ts" ]; then + # Vite project +elif [ -f "next.config.js" ]; then + # Next.js project +elif [ -f "nuxt.config.ts" ]; then + # Nuxt project +fi +``` + +## Common Mistakes to Avoid + +❌ **Blocking operations**: `sleep 60` (blocks dmux) +✅ **Background long tasks**: `slow_operation &` + +❌ **Hardcoded paths**: `/Users/me/project` +✅ **Use variables**: `"$DMUX_ROOT"` + +❌ **Assuming tools exist**: `pnpm install` +✅ **Check first**: `command -v pnpm && pnpm install` + +❌ **No error handling**: Script fails silently +✅ **Set error mode**: `set -e` or check exit codes + +❌ **Forgetting executable bit**: Hook won't run +✅ **Make executable**: `chmod +x` + +❌ **Noisy output**: Clutters dmux logs +✅ **Silent operations**: `curl -s`, `> /dev/null 2>&1` + +❌ **Not testing**: Deploy and hope +✅ **Test manually**: Run with mock env vars first + +## Debugging + +If a hook isn't working: + +1. **Check if file exists**: `ls -la .dmux-hooks/` +2. **Check permissions**: Should show `x` in `rwxr-xr-x` +3. **Check syntax**: `bash -n .dmux-hooks/hook_name` +4. **Test manually**: Set env vars and run +5. **Check logs**: dmux logs to stderr with `[Hooks]` prefix +6. **Simplify**: Remove complex parts, test basic version +7. **Check tool availability**: `command -v required_tool` + +### Debug Mode +```bash +#!/bin/bash +# Add to top of hook for debugging +set -x # Print each command before executing +set -e # Exit on error + +# Your hook logic here +``` + +## Summary Checklist + +When creating a new hook: + +- [ ] Create file in `.dmux-hooks/` +- [ ] Add shebang: `#!/bin/bash` +- [ ] Make executable: `chmod +x` +- [ ] Add `set -e` for error handling +- [ ] Use environment variables (never hardcode paths) +- [ ] Keep blocking hooks chatty with status output +- [ ] Background long operations with `&` only when the hook does not gate the operation +- [ ] Check for required tools before using +- [ ] Test manually with mock env vars +- [ ] Add comments explaining what it does +- [ ] Commit to version control + +## Getting Help + +- **Full documentation**: See `HOOKS.md` in project root +- **Claude-specific tips**: See `CLAUDE.md` in `.dmux-hooks/` +- **Examples**: Check `.dmux-hooks/examples/` directory +- **dmux API**: See `API.md` for REST endpoints + +--- + +*This documentation was auto-generated from dmux source code.* +*Version: 2026-05-25* diff --git a/.dmux-hooks/CLAUDE.md b/.dmux-hooks/CLAUDE.md new file mode 100644 index 00000000..1a5001bb --- /dev/null +++ b/.dmux-hooks/CLAUDE.md @@ -0,0 +1,445 @@ +# dmux Hooks System - Agent Reference + +**Auto-generated documentation for AI agents** + +This document contains everything an AI agent needs to create, modify, and understand dmux hooks. It is automatically generated from the dmux source code and embedded in the binary. + +## What You're Working On + +You are editing hooks for **dmux**, a tmux pane manager that creates AI-powered development workflows. Each pane runs in its own git worktree with an AI agent. + +## Your Goal + +Create executable bash scripts in `.dmux-hooks/` that run automatically at key lifecycle events. + +## Quick Start + +1. **Create a hook file**: `touch .dmux-hooks/worktree_created` +2. **Make it executable**: `chmod +x .dmux-hooks/worktree_created` +3. **Add shebang**: Start with `#!/bin/bash` +4. **Use environment variables**: Access `$DMUX_ROOT`, `$DMUX_WORKTREE_PATH`, etc. +5. **Test it**: Set env vars manually and run the script + +## Hook Execution Model + +- **Mostly non-blocking**: Most hooks run in background (detached processes) +- **Bootstrap-gated**: `worktree_created` blocks agent launch so setup can finish first, with no fixed timeout +- **Live bootstrap output**: During `worktree_created`, stdout/stderr is streamed into the new pane's setup UI +- **Failure behavior**: Background hook errors are logged; gated hooks can abort the operation +- **Environment-based**: All context passed via environment variables +- **Version controlled**: Hooks in `.dmux-hooks/` are shared with team +- **Priority resolution**: `.dmux-hooks/` → `.dmux/hooks/` → `~/.dmux/hooks/` + +## Available Hooks + +### Pane Lifecycle Hooks + +| Hook | When | Common Use Cases | +|------|------|------------------| +| `before_pane_create` | Before pane creation | Validation, notifications, pre-flight checks | +| `pane_created` | After pane, before worktree | Configure tmux settings, prepare environment | +| `worktree_created` | After worktree creation, before agent launch | Install deps, copy configs, setup git | +| `before_pane_close` | Before closing | Save state, backup uncommitted work | +| `pane_closed` | After closed | Cleanup resources, analytics, notifications | + +### Worktree Lifecycle Hooks + +| Hook | When | Common Use Cases | +|------|------|------------------| +| `before_worktree_remove` | Before worktree removal | Archive worktree, save artifacts | +| `worktree_removed` | After worktree removed | Cleanup external references | + +### Merge Lifecycle Hooks + +| Hook | When | Common Use Cases | +|------|------|------------------| +| `pre_merge` | Before merge operation | Run final tests, create backups | +| `post_merge` | After successful merge | Deploy, close issues, notify team | + +### Interactive Hooks (with HTTP callbacks) + +| Hook | When | Common Use Cases | +|------|------|------------------| +| `run_test` | When tests triggered | Run test suite, report status via HTTP | +| `run_dev` | When dev server triggered | Start dev server, create tunnel, report URL | + + +## Environment Variables + +### Always Available +```bash +DMUX_ROOT="/path/to/project" # Project root directory +DMUX_SERVER_PORT="3142" # HTTP server port +``` + +### Pane Context (most hooks) +```bash +DMUX_PANE_ID="dmux-1234567890" # dmux pane identifier +DMUX_SLUG="fix-auth-bug" # Branch/worktree name +DMUX_PROMPT="Fix authentication bug" # User's prompt +DMUX_AGENT="claude" # Agent type (registry id, e.g. claude, codex, opencode) +DMUX_TMUX_PANE_ID="%38" # tmux pane ID +``` + +### Worktree Context +```bash +DMUX_WORKTREE_PATH="/path/.dmux/worktrees/fix-auth-bug" +DMUX_BRANCH="fix-auth-bug" # Same as slug +``` + +### Bootstrap Progress Context +```bash +DMUX_PROGRESS="1" # Set when output is shown in the new pane setup UI +DMUX_STATUS_PREFIX="DMUX_STATUS:" # Prefix for clean status messages +``` + +`worktree_created` can emit progress while it runs. Any stdout/stderr line is shown in the setup UI; lines prefixed with `$DMUX_STATUS_PREFIX` are displayed without the prefix. + +```bash +status() { + if [ "${DMUX_PROGRESS:-0}" = "1" ]; then + echo "${DMUX_STATUS_PREFIX:-DMUX_STATUS:} $*" + else + echo "[Hook] $*" + fi +} +``` + +### Merge Context +```bash +DMUX_TARGET_BRANCH="main" # Branch being merged into +``` + +## HTTP Callback API + +Interactive hooks (`run_test` and `run_dev`) can update dmux UI via HTTP. + +### Update Test Status +```bash +curl -X PUT "http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/test" -H "Content-Type: application/json" -d '{"status": "running", "output": "optional test output"}' + +# Status values: "running" | "passed" | "failed" +``` + +### Update Dev Server +```bash +curl -X PUT "http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/dev" -H "Content-Type: application/json" -d '{"status": "running", "url": "http://localhost:3000"}' + +# Status values: "running" | "stopped" +# url: Can be localhost or tunnel URL (ngrok, cloudflared, etc.) +``` + +## Common Patterns + +### Pattern 1: Install Dependencies +```bash +#!/bin/bash +# .dmux-hooks/worktree_created + +cd "$DMUX_WORKTREE_PATH" + +status() { + if [ "${DMUX_PROGRESS:-0}" = "1" ]; then + echo "${DMUX_STATUS_PREFIX:-DMUX_STATUS:} $*" + else + echo "[Hook] $*" + fi +} + +if [ -f "pnpm-lock.yaml" ]; then + status "Installing dependencies with pnpm" + pnpm install --prefer-offline +elif [ -f "package-lock.json" ]; then + status "Installing dependencies with npm" + npm install +elif [ -f "yarn.lock" ]; then + status "Installing dependencies with yarn" + yarn install +elif [ -f "Gemfile" ]; then + status "Installing gems" + bundle install +elif [ -f "requirements.txt" ]; then + status "Installing Python dependencies" + pip install -r requirements.txt +elif [ -f "Cargo.toml" ]; then + status "Building Rust project" + cargo build +fi +``` + +### Pattern 2: Copy Configuration +```bash +#!/bin/bash +# .dmux-hooks/worktree_created + +# Copy environment file +if [ -f "$DMUX_ROOT/.env.local" ]; then + cp "$DMUX_ROOT/.env.local" "$DMUX_WORKTREE_PATH/.env.local" +fi + +# Copy other config files +for file in .env.development .npmrc .yarnrc; do + if [ -f "$DMUX_ROOT/$file" ]; then + cp "$DMUX_ROOT/$file" "$DMUX_WORKTREE_PATH/$file" + fi +done +``` + +### Pattern 3: Run Tests with Status Updates +```bash +#!/bin/bash +# .dmux-hooks/run_test + +set -e +cd "$DMUX_WORKTREE_PATH" +API="http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/test" + +# Update: starting +curl -s -X PUT "$API" -H "Content-Type: application/json" -d '{"status": "running"}' > /dev/null + +# Run tests and capture output +OUTPUT_FILE="/tmp/dmux-test-$DMUX_PANE_ID.txt" +if pnpm test > "$OUTPUT_FILE" 2>&1; then + STATUS="passed" +else + STATUS="failed" +fi + +# Get output (truncate if too long) +OUTPUT=$(head -c 5000 "$OUTPUT_FILE") + +# Update: complete +curl -s -X PUT "$API" -H "Content-Type: application/json" -d "$(jq -n --arg status "$STATUS" --arg output "$OUTPUT" '{status: $status, output: $output}')" > /dev/null + +rm -f "$OUTPUT_FILE" +``` + +### Pattern 4: Dev Server with Tunnel +```bash +#!/bin/bash +# .dmux-hooks/run_dev + +set -e +cd "$DMUX_WORKTREE_PATH" +API="http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/dev" + +# Start dev server in background +LOG_FILE="/tmp/dmux-dev-$DMUX_PANE_ID.log" +pnpm dev > "$LOG_FILE" 2>&1 & +DEV_PID=$! + +# Wait for server to start +sleep 5 + +# Detect port from logs +PORT=$(grep -oP 'localhost:Kd+' "$LOG_FILE" | head -1) +[ -z "$PORT" ] && PORT=3000 + +# Optional: Create tunnel with cloudflared +if command -v cloudflared &> /dev/null; then + TUNNEL=$(cloudflared tunnel --url "http://localhost:$PORT" 2>&1 | grep -oP 'https://[a-z0-9-]+.trycloudflare.com' | head -1) + URL="${TUNNEL:-http://localhost:$PORT}" +else + URL="http://localhost:$PORT" +fi + +# Report status +curl -s -X PUT "$API" -H "Content-Type: application/json" -d "{"status": "running", "url": "$URL"}" > /dev/null + +echo "[Hook] Dev server running at $URL (PID: $DEV_PID)" +``` + +### Pattern 5: Post-Merge Deployment +```bash +#!/bin/bash +# .dmux-hooks/post_merge + +set -e +cd "$DMUX_ROOT" + +# Only deploy from main/master +if [ "$DMUX_TARGET_BRANCH" != "main" ] && [ "$DMUX_TARGET_BRANCH" != "master" ]; then + exit 0 +fi + +# Push to remote +git push origin "$DMUX_TARGET_BRANCH" + +# Trigger deployment (example: Vercel) +if [ -n "$VERCEL_TOKEN" ]; then + curl -s -X POST "https://api.vercel.com/v1/deployments" -H "Authorization: Bearer $VERCEL_TOKEN" -H "Content-Type: application/json" -d '{"name": "my-project"}' > /dev/null +fi + +# Close GitHub issue if prompt contains #123 +ISSUE=$(echo "$DMUX_PROMPT" | grep -oP '#Kd+' | head -1) +if [ -n "$ISSUE" ] && command -v gh &> /dev/null; then + gh issue close "$ISSUE" -c "Resolved in $DMUX_SLUG, merged to $DMUX_TARGET_BRANCH" 2>/dev/null || true +fi +``` + +## Best Practices + +1. **Always start with shebang**: `#!/bin/bash` +2. **Set error handling**: `set -e` (exit on error) +3. **Make executable**: `chmod +x .dmux-hooks/hook_name` +4. **Background long operations**: Append `&` to avoid blocking +5. **Check for required tools**: `command -v tool &> /dev/null` +6. **Log for debugging**: `echo "[Hook] message" >> "$DMUX_ROOT/.dmux/hooks.log"` +7. **Handle missing vars gracefully**: `[ -z "$VAR" ] && exit 0` +8. **Use silent curl**: `curl -s` to avoid noise in logs +9. **Clean up temp files**: Remove files in `/tmp/` +10. **Test before committing**: Run hooks manually with mock env vars + +## Testing Hooks + +### Manual Testing +```bash +# 1. Set environment variables +export DMUX_ROOT="$(pwd)" +export DMUX_PANE_ID="test-pane" +export DMUX_SLUG="test-branch" +export DMUX_WORKTREE_PATH="$(pwd)" +export DMUX_SERVER_PORT="3142" +export DMUX_AGENT="claude" +export DMUX_PROMPT="Test prompt" + +# 2. Run hook directly +./.dmux-hooks/worktree_created + +# 3. Check exit code +echo $? # Should be 0 for success +``` + +### Syntax Check +```bash +# Check for syntax errors without running +bash -n ./.dmux-hooks/worktree_created +``` + +### Shellcheck (if available) +```bash +shellcheck ./.dmux-hooks/worktree_created +``` + +## Project Context Analysis + +Before creating hooks, analyze these files in the project: + +### Package Manager Detection +```bash +# Check which package manager is used +if [ -f "pnpm-lock.yaml" ]; then + # Use: pnpm install, pnpm test, pnpm dev +elif [ -f "package-lock.json" ]; then + # Use: npm install, npm test, npm run dev +elif [ -f "yarn.lock" ]; then + # Use: yarn install, yarn test, yarn dev +fi +``` + +### Test Command Discovery +```bash +# Read package.json to find test command +cat package.json | grep '"test"' +# Or with jq: +jq -r '.scripts.test' package.json +``` + +### Dev Command Discovery +```bash +# Read package.json to find dev command +cat package.json | grep '"dev"' +# Or with jq: +jq -r '.scripts.dev' package.json +``` + +### Environment Variables +```bash +# Check for .env files to copy +ls -la | grep '.env' +``` + +### Build System +```bash +# Detect build system +if [ -f "vite.config.ts" ]; then + # Vite project +elif [ -f "next.config.js" ]; then + # Next.js project +elif [ -f "nuxt.config.ts" ]; then + # Nuxt project +fi +``` + +## Common Mistakes to Avoid + +❌ **Blocking operations**: `sleep 60` (blocks dmux) +✅ **Background long tasks**: `slow_operation &` + +❌ **Hardcoded paths**: `/Users/me/project` +✅ **Use variables**: `"$DMUX_ROOT"` + +❌ **Assuming tools exist**: `pnpm install` +✅ **Check first**: `command -v pnpm && pnpm install` + +❌ **No error handling**: Script fails silently +✅ **Set error mode**: `set -e` or check exit codes + +❌ **Forgetting executable bit**: Hook won't run +✅ **Make executable**: `chmod +x` + +❌ **Noisy output**: Clutters dmux logs +✅ **Silent operations**: `curl -s`, `> /dev/null 2>&1` + +❌ **Not testing**: Deploy and hope +✅ **Test manually**: Run with mock env vars first + +## Debugging + +If a hook isn't working: + +1. **Check if file exists**: `ls -la .dmux-hooks/` +2. **Check permissions**: Should show `x` in `rwxr-xr-x` +3. **Check syntax**: `bash -n .dmux-hooks/hook_name` +4. **Test manually**: Set env vars and run +5. **Check logs**: dmux logs to stderr with `[Hooks]` prefix +6. **Simplify**: Remove complex parts, test basic version +7. **Check tool availability**: `command -v required_tool` + +### Debug Mode +```bash +#!/bin/bash +# Add to top of hook for debugging +set -x # Print each command before executing +set -e # Exit on error + +# Your hook logic here +``` + +## Summary Checklist + +When creating a new hook: + +- [ ] Create file in `.dmux-hooks/` +- [ ] Add shebang: `#!/bin/bash` +- [ ] Make executable: `chmod +x` +- [ ] Add `set -e` for error handling +- [ ] Use environment variables (never hardcode paths) +- [ ] Keep blocking hooks chatty with status output +- [ ] Background long operations with `&` only when the hook does not gate the operation +- [ ] Check for required tools before using +- [ ] Test manually with mock env vars +- [ ] Add comments explaining what it does +- [ ] Commit to version control + +## Getting Help + +- **Full documentation**: See `HOOKS.md` in project root +- **Claude-specific tips**: See `CLAUDE.md` in `.dmux-hooks/` +- **Examples**: Check `.dmux-hooks/examples/` directory +- **dmux API**: See `API.md` for REST endpoints + +--- + +*This documentation was auto-generated from dmux source code.* +*Version: 2026-05-25* diff --git a/.dmux-hooks/README.md b/.dmux-hooks/README.md new file mode 100644 index 00000000..202222ee --- /dev/null +++ b/.dmux-hooks/README.md @@ -0,0 +1,53 @@ +# dmux Hooks + +This directory contains hooks that run automatically at key lifecycle events in dmux. + +## Quick Start + +1. **Read the documentation**: + - `AGENTS.md` - Complete reference (for any AI agent) + - `CLAUDE.md` - Same content (Claude Code looks for this filename) + +2. **Check examples**: + - `examples/` directory contains starter templates + +3. **Create a hook**: + ```bash + touch worktree_created + chmod +x worktree_created + nano worktree_created + ``` + +4. **Test it**: + ```bash + export DMUX_ROOT="$(pwd)" + export DMUX_WORKTREE_PATH="$(pwd)" + ./worktree_created + ``` + +## Available Hooks + +- `before_pane_create` - Before pane creation +- `pane_created` - After pane created +- `worktree_created` - After worktree setup +- `before_pane_close` - Before closing +- `pane_closed` - After closed +- `before_worktree_remove` - Before worktree removal +- `worktree_removed` - After worktree removed +- `pre_merge` - Before merge +- `post_merge` - After merge +- `run_test` - When running tests +- `run_dev` - When starting dev server + +## Documentation + +See `AGENTS.md` or `CLAUDE.md` for complete documentation including: +- Environment variables +- HTTP callback API +- Common patterns +- Best practices +- Testing strategies + +## Note + +This directory is **version controlled**. Hooks you create here will be shared with your team. diff --git a/.dmux-hooks/examples/post_merge.example b/.dmux-hooks/examples/post_merge.example new file mode 100755 index 00000000..9b5c58d1 --- /dev/null +++ b/.dmux-hooks/examples/post_merge.example @@ -0,0 +1,66 @@ +#!/bin/bash +# Example: post_merge hook +# +# This hook runs after a successful merge into the target branch. +# Use it to trigger deployments, close issues, notify teams, etc. + +set -e + +echo "[Hook] Post-merge processing for $DMUX_SLUG → $DMUX_TARGET_BRANCH" + +cd "$DMUX_ROOT" + +# Push to remote if merging to main/master +if [ "$DMUX_TARGET_BRANCH" = "main" ] || [ "$DMUX_TARGET_BRANCH" = "master" ]; then + echo "[Hook] Pushing to origin/$DMUX_TARGET_BRANCH" + git push origin "$DMUX_TARGET_BRANCH" + + # Optional: Trigger deployment + # if [ -n "$VERCEL_TOKEN" ]; then + # echo "[Hook] Triggering Vercel deployment..." + # curl -X POST "https://api.vercel.com/v1/deployments" \ + # -H "Authorization: Bearer $VERCEL_TOKEN" \ + # -H "Content-Type: application/json" \ + # -d '{ + # "name": "my-project", + # "gitSource": { + # "type": "github", + # "ref": "main" + # } + # }' + # fi +fi + +# Close related GitHub issue (if prompt contains #123 format) +ISSUE_NUM=$(echo "$DMUX_PROMPT" | grep -oP '#\K\d+' | head -1) +if [ -n "$ISSUE_NUM" ]; then + echo "[Hook] Closing GitHub issue #$ISSUE_NUM" + if command -v gh &> /dev/null; then + gh issue close "$ISSUE_NUM" \ + -c "Resolved in branch $DMUX_SLUG, merged to $DMUX_TARGET_BRANCH" \ + 2>/dev/null || echo "[Hook] Warning: Failed to close issue (maybe already closed?)" + else + echo "[Hook] GitHub CLI (gh) not found, skipping issue close" + fi +fi + +# Send notification to Slack +# if [ -n "$SLACK_WEBHOOK" ]; then +# echo "[Hook] Sending Slack notification" +# curl -s -X POST "$SLACK_WEBHOOK" \ +# -H "Content-Type: application/json" \ +# -d "{ +# \"text\": \"Merged: $DMUX_SLUG → $DMUX_TARGET_BRANCH\", +# \"blocks\": [ +# { +# \"type\": \"section\", +# \"text\": { +# \"type\": \"mrkdwn\", +# \"text\": \"*Branch Merged* :rocket:\n\n*From:* \`$DMUX_SLUG\`\n*To:* \`$DMUX_TARGET_BRANCH\`\n*Task:* $DMUX_PROMPT\" +# } +# } +# ] +# }" > /dev/null +# fi + +echo "[Hook] Post-merge processing complete" diff --git a/.dmux-hooks/examples/run_dev.example b/.dmux-hooks/examples/run_dev.example new file mode 100755 index 00000000..c2005f43 --- /dev/null +++ b/.dmux-hooks/examples/run_dev.example @@ -0,0 +1,62 @@ +#!/bin/bash +# Example: run_dev hook +# +# This hook starts a dev server and optionally creates a tunnel for sharing. +# It reports the server URL back to dmux via the HTTP API. + +set -e + +echo "[Hook] Starting dev server for $DMUX_SLUG" + +cd "$DMUX_WORKTREE_PATH" +API_URL="http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/dev" + +# Update status: starting +curl -s -X PUT "$API_URL" \ + -H "Content-Type: application/json" \ + -d '{"status": "running"}' > /dev/null + +# Start dev server in background +# Adjust the command for your project (pnpm dev, npm run dev, vite, etc.) +LOG_FILE="/tmp/dmux-dev-$DMUX_PANE_ID.log" +pnpm dev > "$LOG_FILE" 2>&1 & +DEV_PID=$! + +# Wait for server to be ready +echo "[Hook] Waiting for dev server to start..." +sleep 5 + +# Detect port from log output +# Adjust the grep pattern for your dev server's output format +PORT=$(grep -oP '(?<=localhost:)\d+' "$LOG_FILE" | head -1) + +if [ -z "$PORT" ]; then + echo "[Hook] Warning: Could not detect port from logs, using default 3000" + PORT=3000 +fi + +LOCAL_URL="http://localhost:$PORT" +echo "[Hook] Dev server running at $LOCAL_URL" + +# Optional: Create a public tunnel (uncomment to enable) +# Requires ngrok, cloudflared, or another tunneling tool + +# Example with cloudflared: +# TUNNEL_URL=$(cloudflared tunnel --url "$LOCAL_URL" 2>&1 | \ +# grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' | head -1) + +# Example with ngrok: +# TUNNEL_URL=$(ngrok http $PORT --log=stdout 2>&1 | \ +# grep -oP 'url=https://[^"]+' | head -1 | cut -d= -f2) + +# For now, just use local URL (uncomment tunnel code above to enable) +FINAL_URL="$LOCAL_URL" + +# Report status back to dmux +curl -s -X PUT "$API_URL" \ + -H "Content-Type: application/json" \ + -d "{\"status\": \"running\", \"url\": \"$FINAL_URL\"}" > /dev/null + +echo "[Hook] Dev server ready at: $FINAL_URL" +echo "[Hook] Dev server PID: $DEV_PID" +echo "[Hook] Log file: $LOG_FILE" diff --git a/.dmux-hooks/examples/run_test.example b/.dmux-hooks/examples/run_test.example new file mode 100755 index 00000000..6b286a1c --- /dev/null +++ b/.dmux-hooks/examples/run_test.example @@ -0,0 +1,61 @@ +#!/bin/bash +# Example: run_test hook +# +# This hook runs tests and reports the status back to dmux via the HTTP API. +# Status updates appear in real-time in the dmux UI. + +set -e + +echo "[Hook] Running tests for $DMUX_SLUG" + +cd "$DMUX_WORKTREE_PATH" +API_URL="http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/test" + +# Update status: running +curl -s -X PUT "$API_URL" \ + -H "Content-Type: application/json" \ + -d '{"status": "running"}' > /dev/null + +echo "[Hook] Running test suite..." + +# Capture test output +OUTPUT_FILE="/tmp/dmux-test-$DMUX_PANE_ID.txt" + +# Run tests (adjust command for your project) +# Examples: +# - pnpm test +# - npm test +# - vitest run +# - jest +# - pytest +# - cargo test +if pnpm test > "$OUTPUT_FILE" 2>&1; then + STATUS="passed" + echo "[Hook] Tests passed ✓" +else + STATUS="failed" + echo "[Hook] Tests failed ✗" +fi + +# Get output (truncate if too long) +OUTPUT=$(head -c 5000 "$OUTPUT_FILE") + +# Report results back to dmux +curl -s -X PUT "$API_URL" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg status "$STATUS" \ + --arg output "$OUTPUT" \ + '{status: $status, output: $output}')" > /dev/null + +# Cleanup +rm -f "$OUTPUT_FILE" + +echo "[Hook] Test results reported to dmux" + +# Exit with test status +if [ "$STATUS" = "passed" ]; then + exit 0 +else + exit 1 +fi diff --git a/.dmux-hooks/examples/worktree_created.example b/.dmux-hooks/examples/worktree_created.example new file mode 100755 index 00000000..61c2979b --- /dev/null +++ b/.dmux-hooks/examples/worktree_created.example @@ -0,0 +1,48 @@ +#!/bin/bash +# Example: worktree_created hook +# +# This hook runs after a new worktree is created and before the agent launches. +# Use it to set up the worktree environment (install deps, copy configs, etc.) +# Stdout/stderr is streamed into the new pane's setup UI while this hook runs. +# dmux waits for this hook without a fixed timeout. + +set -e # Exit on error + +status() { + if [ "${DMUX_PROGRESS:-0}" = "1" ]; then + echo "${DMUX_STATUS_PREFIX:-DMUX_STATUS:} $*" + else + echo "[Hook] $*" + fi +} + +status "Setting up worktree: $DMUX_SLUG" + +cd "$DMUX_WORKTREE_PATH" + +# Install dependencies before the agent launches. +if [ -f "pnpm-lock.yaml" ]; then + status "Installing dependencies with pnpm" + pnpm install --prefer-offline +elif [ -f "package-lock.json" ]; then + status "Installing dependencies with npm" + npm install +elif [ -f "yarn.lock" ]; then + status "Installing dependencies with yarn" + yarn install +fi + +# Copy environment file if it exists +if [ -f "$DMUX_ROOT/.env.local" ]; then + status "Copying .env.local" + cp "$DMUX_ROOT/.env.local" "$DMUX_WORKTREE_PATH/.env.local" +fi + +# Keep existing git author identity. +# Do not set git user.name/user.email in this hook. + +# Create a log entry +echo "[$(date)] Created worktree: $DMUX_SLUG | Agent: $DMUX_AGENT | Prompt: $DMUX_PROMPT" \ + >> "$DMUX_ROOT/.dmux/worktree_history.log" + +status "Worktree setup complete" diff --git a/.gitignore b/.gitignore index f7d24dad..47f0d767 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ s3_cache /*-source/ /.cache/ /internal/router/root/dist/ +.dmux/ From 6066eb114b4e7df7f65337b0dd9434cbf7acd2fd Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 18 Jun 2026 10:31:11 +0800 Subject: [PATCH 2/3] refactor(auth): decouple auth from push via domain events Introduce internal/listener as a domain event bus so oauth and user modules emit AdminLoggedIn without depending on admin/push. Register push handlers explicitly at the router composition root, replacing init() side-effect registration and blank imports. --- .../admin/push/custom_events/admin_login.go | 17 +++----- .../apps/admin/push/custom_events/register.go | 17 ++++++++ internal/apps/oauth/sources.go | 4 +- internal/apps/user/routers.go | 4 +- internal/listener/.gitkeep | 0 internal/listener/admin_login.go | 42 +++++++++++++++++++ internal/router/router.go | 6 ++- 7 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 internal/apps/admin/push/custom_events/register.go delete mode 100644 internal/listener/.gitkeep create mode 100644 internal/listener/admin_login.go diff --git a/internal/apps/admin/push/custom_events/admin_login.go b/internal/apps/admin/push/custom_events/admin_login.go index cc115065..dab5811d 100644 --- a/internal/apps/admin/push/custom_events/admin_login.go +++ b/internal/apps/admin/push/custom_events/admin_login.go @@ -9,7 +9,7 @@ import ( "time" "github.com/Rain-kl/Wavelet/internal/apps/admin/push" - "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/listener" ) // AdminLogin is the metadata definition for the admin login event. @@ -24,20 +24,15 @@ var AdminLogin = push.EventMetadata{ Description: "当管理员成功登录系统时触发此通知", } -func init() { - push.RegisterBuiltInEvent(AdminLogin) -} - -// TriggerAdminLoginEvent triggers the admin login event asynchronously. -func TriggerAdminLoginEvent(ctx context.Context, user *model.User, ip string) { - if user == nil || !user.IsAdmin { +func handleAdminLogin(ctx context.Context, event listener.AdminLoggedIn) { + if event.User == nil { return } body := map[string]any{ - "user": user, - "ip": ip, + "user": event.User, + "ip": event.IP, "time": time.Now().Format("2006-01-02 15:04:05"), } push.DefaultTrigger.Trigger(ctx, AdminLogin, body) -} +} \ No newline at end of file diff --git a/internal/apps/admin/push/custom_events/register.go b/internal/apps/admin/push/custom_events/register.go new file mode 100644 index 00000000..e26bb4bc --- /dev/null +++ b/internal/apps/admin/push/custom_events/register.go @@ -0,0 +1,17 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package custom_events + +import ( + "github.com/Rain-kl/Wavelet/internal/apps/admin/push" + "github.com/Rain-kl/Wavelet/internal/listener" +) + +// Register wires push notification handlers for domain events and registers +// built-in event metadata. Must be called once during application bootstrap +// before push.SyncEvents. +func Register() { + push.RegisterBuiltInEvent(AdminLogin) + listener.OnAdminLoggedIn(handleAdminLogin) +} \ No newline at end of file diff --git a/internal/apps/oauth/sources.go b/internal/apps/oauth/sources.go index 3194c0c8..d1caa0bb 100644 --- a/internal/apps/oauth/sources.go +++ b/internal/apps/oauth/sources.go @@ -14,11 +14,11 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events" "github.com/Rain-kl/Wavelet/internal/common" "github.com/Rain-kl/Wavelet/internal/common/response" "github.com/Rain-kl/Wavelet/internal/config" "github.com/Rain-kl/Wavelet/internal/db" + "github.com/Rain-kl/Wavelet/internal/listener" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/coreos/go-oidc/v3/oidc" @@ -678,7 +678,7 @@ func handleCallbackLogin(ctx context.Context, c *gin.Context, source *model.Auth logger.InfoF(ctx, "[LoginAudit] successful OAuth login via source: %s, external ID: %s, user: %s, ID: %d, IP: %s", source.Name, userInfo.Sub, user.Username, user.ID, c.ClientIP()) - custom_events.TriggerAdminLoginEvent(ctx, &user, c.ClientIP()) + listener.EmitAdminLoggedIn(ctx, &user, c.ClientIP()) c.JSON(http.StatusOK, response.OK(buildCallbackResult(&user, "logged_in"))) } diff --git a/internal/apps/user/routers.go b/internal/apps/user/routers.go index fad35df2..60efc1ac 100644 --- a/internal/apps/user/routers.go +++ b/internal/apps/user/routers.go @@ -8,12 +8,12 @@ import ("context" "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events" "github.com/Rain-kl/Wavelet/internal/apps/oauth" "github.com/Rain-kl/Wavelet/internal/common" "github.com/Rain-kl/Wavelet/internal/config" "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/db/idgen" + "github.com/Rain-kl/Wavelet/internal/listener" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/util" "github.com/Rain-kl/Wavelet/pkg/logger" @@ -172,7 +172,7 @@ func Login(c *gin.Context) { logger.InfoF(ctx, "[LoginAudit] successful login for user: %s, ID: %d, IP: %s", user.Username, user.ID, c.ClientIP()) - custom_events.TriggerAdminLoginEvent(ctx, &user, c.ClientIP()) + listener.EmitAdminLoggedIn(ctx, &user, c.ClientIP()) c.JSON(http.StatusOK, response.OK(oauth.BuildBasicUserInfo(&user, needChangePassword))) } diff --git a/internal/listener/.gitkeep b/internal/listener/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/internal/listener/admin_login.go b/internal/listener/admin_login.go new file mode 100644 index 00000000..365e9ceb --- /dev/null +++ b/internal/listener/admin_login.go @@ -0,0 +1,42 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package listener provides domain event dispatch for cross-module integration. +// Core domains emit events here; operational modules (push, webhooks, etc.) +// subscribe at the application composition root. +package listener + +import ( + "context" + + "github.com/Rain-kl/Wavelet/internal/model" +) + +// AdminLoggedIn is emitted when an administrator successfully authenticates. +type AdminLoggedIn struct { + User *model.User + IP string +} + +// AdminLoggedInHandler handles administrator login domain events. +type AdminLoggedInHandler func(ctx context.Context, event AdminLoggedIn) + +var adminLoggedInHandlers []AdminLoggedInHandler + +// OnAdminLoggedIn registers a handler for administrator login events. +// Handlers must be registered during application bootstrap before serving traffic. +func OnAdminLoggedIn(handler AdminLoggedInHandler) { + adminLoggedInHandlers = append(adminLoggedInHandlers, handler) +} + +// EmitAdminLoggedIn dispatches an administrator login event to all registered handlers. +func EmitAdminLoggedIn(ctx context.Context, user *model.User, ip string) { + if user == nil || !user.IsAdmin { + return + } + + event := AdminLoggedIn{User: user, IP: ip} + for _, handler := range adminLoggedInHandlers { + handler(ctx, event) + } +} \ No newline at end of file diff --git a/internal/router/router.go b/internal/router/router.go index b059c4a7..415de15f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -16,12 +16,11 @@ import ( "time" admin_push "github.com/Rain-kl/Wavelet/internal/apps/admin/push" + "github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events" "github.com/Rain-kl/Wavelet/internal/apps/risk_control" router_root "github.com/Rain-kl/Wavelet/internal/router/root" v1 "github.com/Rain-kl/Wavelet/internal/router/v1" - // Swagger 文档生成 - _ "github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events" "github.com/Rain-kl/Wavelet/internal/apps/oauth" "github.com/Rain-kl/Wavelet/internal/config" otel_trace "github.com/Rain-kl/Wavelet/pkg/trace" @@ -41,6 +40,9 @@ func Serve() { // 初始化 ClickHouse 异步日志写入器 risk_control.InitLogWriter() + // 装配推送模块与领域事件的集成(组合根显式注册,避免 init 副作用) + custom_events.Register() + // 运行内置事件同步 if err := admin_push.SyncEvents(context.Background()); err != nil { log.Printf("[API] sync push events failed: %v\n", err) From 22c2ad5c73e97381db13880e85c49f56b5c41f20 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 18 Jun 2026 10:34:49 +0800 Subject: [PATCH 3/3] refactor(task): replace init registration with bootstrap wiring Introduce internal/bootstrap as the composition root with sync.Once guards for task handler registration and push listener wiring. Replace the single OnTaskCompleted global hook with multi-subscriber handlers and remove init()-driven side effects from worker, admin task, and push. --- internal/apps/admin/push/task_listener.go | 5 +- internal/apps/admin/task/routers.go | 5 -- internal/bootstrap/bootstrap.go | 65 +++++++++++++++++++++++ internal/cmd/all.go | 2 + internal/router/router.go | 6 +-- internal/task/executor.go | 26 +++++++-- internal/task/scheduler/scheduler.go | 3 ++ internal/task/worker/worker.go | 8 +-- 8 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 internal/bootstrap/bootstrap.go diff --git a/internal/apps/admin/push/task_listener.go b/internal/apps/admin/push/task_listener.go index 39fbcd69..5dafd187 100644 --- a/internal/apps/admin/push/task_listener.go +++ b/internal/apps/admin/push/task_listener.go @@ -15,8 +15,9 @@ import ( "github.com/Rain-kl/Wavelet/pkg/logger" ) -func init() { - task.OnTaskCompleted = handleTaskCompleted +// RegisterTaskListeners subscribes push notification handlers to task completion events. +func RegisterTaskListeners() { + task.OnTaskCompleted(handleTaskCompleted) } // handleTaskCompleted handles task completions and triggers appropriate push events. diff --git a/internal/apps/admin/task/routers.go b/internal/apps/admin/task/routers.go index 580a2261..974fd762 100644 --- a/internal/apps/admin/task/routers.go +++ b/internal/apps/admin/task/routers.go @@ -13,7 +13,6 @@ import ("fmt" "github.com/Rain-kl/Wavelet/internal/apps/admin" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/task" - taskhandlers "github.com/Rain-kl/Wavelet/internal/task/handlers" "github.com/Rain-kl/Wavelet/internal/task/scheduler" "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/gin-gonic/gin" @@ -21,10 +20,6 @@ import ("fmt" "github.com/Rain-kl/Wavelet/internal/common/response") -func init() { - taskhandlers.Register() -} - // ListTaskTypes 获取支持的任务类型列表 // @Summary 获取支持的任务类型 // @Description 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限 diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go new file mode 100644 index 00000000..3471ea19 --- /dev/null +++ b/internal/bootstrap/bootstrap.go @@ -0,0 +1,65 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package bootstrap wires cross-module integrations at the application composition root. +// All registrations use sync.Once so entry points can call them safely without import-order side effects. +package bootstrap + +import ( + "sync" + + admin_push "github.com/Rain-kl/Wavelet/internal/apps/admin/push" + "github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events" + taskhandlers "github.com/Rain-kl/Wavelet/internal/task/handlers" +) + +var ( + registerTasksOnce sync.Once + registerPushDomainEventsOnce sync.Once + registerTaskListenersOnce sync.Once +) + +// RegisterTasks registers all built-in task handlers and metadata. +func RegisterTasks() { + registerTasksOnce.Do(func() { + taskhandlers.Register() + }) +} + +// RegisterPushDomainEvents wires push notification handlers for domain events. +func RegisterPushDomainEvents() { + registerPushDomainEventsOnce.Do(func() { + custom_events.Register() + }) +} + +// RegisterTaskListeners wires operational listeners to task framework hooks. +func RegisterTaskListeners() { + registerTaskListenersOnce.Do(func() { + admin_push.RegisterTaskListeners() + }) +} + +// RegisterAPI wires integrations required by the HTTP API process. +func RegisterAPI() { + RegisterTasks() + RegisterPushDomainEvents() +} + +// RegisterWorker wires integrations required by the task worker process. +func RegisterWorker() { + RegisterTasks() + RegisterTaskListeners() +} + +// RegisterScheduler wires integrations required by the task scheduler process. +func RegisterScheduler() { + RegisterTasks() +} + +// RegisterAll wires integrations for fused mode (API + Worker + Scheduler). +func RegisterAll() { + RegisterTasks() + RegisterPushDomainEvents() + RegisterTaskListeners() +} \ No newline at end of file diff --git a/internal/cmd/all.go b/internal/cmd/all.go index 10dde12b..dabebbd6 100644 --- a/internal/cmd/all.go +++ b/internal/cmd/all.go @@ -9,6 +9,7 @@ import ( "log" "sync" + "github.com/Rain-kl/Wavelet/internal/bootstrap" "github.com/Rain-kl/Wavelet/internal/router" "github.com/Rain-kl/Wavelet/internal/task/scheduler" "github.com/Rain-kl/Wavelet/internal/task/worker" @@ -20,6 +21,7 @@ var allCmd = &cobra.Command{ Short: "以融合模式同时启动 API、Worker 和 Scheduler", Run: func(_ *cobra.Command, _ []string) { log.Println("[All] 融合模式启动") + bootstrap.RegisterAll() var wg sync.WaitGroup diff --git a/internal/router/router.go b/internal/router/router.go index 415de15f..ec7849a8 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -16,8 +16,8 @@ import ( "time" admin_push "github.com/Rain-kl/Wavelet/internal/apps/admin/push" - "github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events" "github.com/Rain-kl/Wavelet/internal/apps/risk_control" + "github.com/Rain-kl/Wavelet/internal/bootstrap" router_root "github.com/Rain-kl/Wavelet/internal/router/root" v1 "github.com/Rain-kl/Wavelet/internal/router/v1" @@ -40,8 +40,8 @@ func Serve() { // 初始化 ClickHouse 异步日志写入器 risk_control.InitLogWriter() - // 装配推送模块与领域事件的集成(组合根显式注册,避免 init 副作用) - custom_events.Register() + // 组合根显式装配跨模块集成(避免 init 副作用与 import 顺序依赖) + bootstrap.RegisterAPI() // 运行内置事件同步 if err := admin_push.SyncEvents(context.Background()); err != nil { diff --git a/internal/task/executor.go b/internal/task/executor.go index 74e23117..e41ae65e 100644 --- a/internal/task/executor.go +++ b/internal/task/executor.go @@ -26,8 +26,16 @@ import ( // handlerRegistry 已注册的任务处理器 var handlerRegistry = make(map[string]TaskHandler) -// OnTaskCompleted is a hook called when a task execution completes. -var OnTaskCompleted func(ctx context.Context, execution *model.TaskExecution, result *TaskResult, execErr error) +// CompletedHandler is called when a task execution completes. +type CompletedHandler func(ctx context.Context, execution *model.TaskExecution, result *TaskResult, execErr error) + +var taskCompletedHandlers []CompletedHandler + +// OnTaskCompleted registers a handler for task completion events. +// Handlers must be registered during application bootstrap before processing tasks. +func OnTaskCompleted(handler CompletedHandler) { + taskCompletedHandlers = append(taskCompletedHandlers, handler) +} // RegisterHandler 注册任务处理器 // 传入任务类型标识(对应 constants.go 中的 AsynqTask 常量)和 TaskHandler 实现 @@ -405,9 +413,17 @@ func completeTaskExecution(ctx context.Context, execution *model.TaskExecution, } } - if OnTaskCompleted != nil { - asyncCtx := context.WithoutCancel(ctx) - go OnTaskCompleted(asyncCtx, execution, result, execErr) + notifyTaskCompleted(ctx, execution, result, execErr) +} + +func notifyTaskCompleted(ctx context.Context, execution *model.TaskExecution, result *TaskResult, execErr error) { + if len(taskCompletedHandlers) == 0 { + return + } + + asyncCtx := context.WithoutCancel(ctx) + for _, handler := range taskCompletedHandlers { + go handler(asyncCtx, execution, result, execErr) } } diff --git a/internal/task/scheduler/scheduler.go b/internal/task/scheduler/scheduler.go index 2d1a1718..478a0511 100644 --- a/internal/task/scheduler/scheduler.go +++ b/internal/task/scheduler/scheduler.go @@ -11,6 +11,7 @@ import ( "syscall" "time" + "github.com/Rain-kl/Wavelet/internal/bootstrap" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/task" "github.com/Rain-kl/Wavelet/pkg/logger" @@ -32,6 +33,8 @@ func GetAsynqClient() *asynq.Client { // StartScheduler 启动调度器 (该函数阻塞,直到调度器退出) func StartScheduler() error { + bootstrap.RegisterScheduler() + var err error schedulerOnce.Do(func() { quitChan = make(chan struct{}) diff --git a/internal/task/worker/worker.go b/internal/task/worker/worker.go index 72e2668d..b66d4f65 100644 --- a/internal/task/worker/worker.go +++ b/internal/task/worker/worker.go @@ -7,22 +7,18 @@ package worker import ( "time" + "github.com/Rain-kl/Wavelet/internal/bootstrap" "github.com/Rain-kl/Wavelet/internal/config" "github.com/Rain-kl/Wavelet/internal/task" - taskhandlers "github.com/Rain-kl/Wavelet/internal/task/handlers" "github.com/hibiken/asynq" ) // workerShutdownTimeout Worker 优雅关闭超时时间 const workerShutdownTimeout = 3 * time.Minute -func init() { - // 注册所有任务处理器 - taskhandlers.Register() -} - // StartWorker 启动任务处理服务器 func StartWorker() error { + bootstrap.RegisterWorker() asynqServer := asynq.NewServer( task.RedisOpt, asynq.Config{