diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 5d001ea..2d632c0 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -48,16 +48,32 @@ jobs: working-directory: humanlayer-wui run: bun install + - name: Build daemon for macOS ARM + working-directory: hld + run: GOOS=darwin GOARCH=arm64 go build -o hld-darwin-arm64 ./cmd/hld + + - name: Build humanlayer CLI for macOS ARM + working-directory: hlyr + run: | + bun install + bun run build + bun build ./dist/index.js --compile --target=bun-darwin-arm64 --outfile=humanlayer-darwin-arm64 + chmod +x humanlayer-darwin-arm64 + + - name: Copy binaries to Tauri resources + run: | + mkdir -p humanlayer-wui/src-tauri/bin + cp hld/hld-darwin-arm64 humanlayer-wui/src-tauri/bin/hld + cp hlyr/humanlayer-darwin-arm64 humanlayer-wui/src-tauri/bin/humanlayer + chmod +x humanlayer-wui/src-tauri/bin/hld + chmod +x humanlayer-wui/src-tauri/bin/humanlayer + - name: Build Tauri app (including DMG) working-directory: humanlayer-wui run: bun run tauri build env: APPLE_SIGNING_IDENTITY: "-" # Ad-hoc signing to prevent "damaged" error - - name: Build daemon for macOS ARM - working-directory: hld - run: GOOS=darwin GOARCH=arm64 go build -o hld-darwin-arm64 ./cmd/hld - - name: Upload DMG artifact uses: actions/upload-artifact@v4 with: diff --git a/Makefile b/Makefile index 96c7a8c..003f4e0 100644 --- a/Makefile +++ b/Makefile @@ -500,6 +500,39 @@ wui-nightly-build: cp -r humanlayer-wui/src-tauri/target/release/bundle/macos/CodeLayer.app ~/Applications/ @echo "Installed WUI nightly to ~/Applications/CodeLayer.app" +# Build humanlayer binary for bundling +.PHONY: humanlayer-build +humanlayer-build: + @echo "Building humanlayer CLI binary..." + cd hlyr && bun install && bun run build + @echo "humanlayer binary built at hlyr/dist/index.js" + +# Build humanlayer standalone binary (requires bun) +.PHONY: humanlayer-binary-darwin-arm64 +humanlayer-binary-darwin-arm64: humanlayer-build + @echo "Creating standalone humanlayer binary for macOS ARM64..." + cd hlyr && bun build ./dist/index.js --compile --target=bun-darwin-arm64 --outfile=humanlayer-darwin-arm64 + chmod +x hlyr/humanlayer-darwin-arm64 + @echo "Standalone binary created at hlyr/humanlayer-darwin-arm64" + +# Build CodeLayer with bundled daemon and humanlayer +.PHONY: codelayer-bundle +codelayer-bundle: + @echo "Building daemon for bundling..." + cd hld && GOOS=darwin GOARCH=arm64 go build -o hld-darwin-arm64 ./cmd/hld + @echo "Building humanlayer for bundling..." + cd hlyr && bun install && bun run build + cd hlyr && bun build ./dist/index.js --compile --target=bun-darwin-arm64 --outfile=humanlayer-darwin-arm64 + @echo "Copying binaries to CodeLayer resources..." + mkdir -p humanlayer-wui/src-tauri/bin + cp hld/hld-darwin-arm64 humanlayer-wui/src-tauri/bin/hld + cp hlyr/humanlayer-darwin-arm64 humanlayer-wui/src-tauri/bin/humanlayer + chmod +x humanlayer-wui/src-tauri/bin/hld + chmod +x humanlayer-wui/src-tauri/bin/humanlayer + @echo "Building CodeLayer with bundled binaries..." + cd humanlayer-wui && bun run tauri build + @echo "Build complete!" + # Open nightly WUI .PHONY: wui-nightly wui-nightly: wui-nightly-build @@ -570,6 +603,7 @@ daemon-dev: daemon-dev-build echo "$(TIMESTAMP) starting dev daemon in $$(pwd)" > ~/.humanlayer/logs/daemon-dev-$(TIMESTAMP).log cd hld && HUMANLAYER_DATABASE_PATH=~/.humanlayer/daemon-dev.db \ HUMANLAYER_DAEMON_SOCKET=~/.humanlayer/daemon-dev.sock \ + HUMANLAYER_DAEMON_HTTP_PORT=0 \ HUMANLAYER_DAEMON_VERSION_OVERRIDE=$$(git branch --show-current) \ ./run-with-logging.sh ~/.humanlayer/logs/daemon-dev-$(TIMESTAMP).log ./hld-dev @@ -581,6 +615,10 @@ wui-dev: echo "$(TIMESTAMP) starting dev wui in $$(pwd)" > ~/.humanlayer/logs/wui-dev-$(TIMESTAMP).log cd humanlayer-wui && HUMANLAYER_DAEMON_SOCKET=~/.humanlayer/daemon-dev.sock bun run tauri dev 2>&1 | tee -a ~/.humanlayer/logs/wui-dev-$(TIMESTAMP).log +# Alias for wui-dev that ensures daemon is built first +.PHONY: codelayer-dev +codelayer-dev: daemon-dev-build wui-dev + # Show current dev environment setup .PHONY: dev-status dev-status: diff --git a/hld/daemon/daemon.go b/hld/daemon/daemon.go index a1de97d..2dbdaef 100644 --- a/hld/daemon/daemon.go +++ b/hld/daemon/daemon.go @@ -115,12 +115,9 @@ func New() (*Daemon, error) { approvalManager := approval.NewManager(conversationStore, eventBus) slog.Debug("local approval manager created successfully") - // Create HTTP server if enabled - var httpServer *HTTPServer - if cfg.HTTPPort > 0 { - slog.Info("creating HTTP server", "port", cfg.HTTPPort) - httpServer = NewHTTPServer(cfg, sessionManager, approvalManager, conversationStore, eventBus) - } + // Create HTTP server (always enabled, port 0 means dynamic allocation) + slog.Info("creating HTTP server", "port", cfg.HTTPPort) + httpServer := NewHTTPServer(cfg, sessionManager, approvalManager, conversationStore, eventBus) return &Daemon{ config: cfg, diff --git a/hld/daemon/http_server.go b/hld/daemon/http_server.go index 3b8c284..3817f09 100644 --- a/hld/daemon/http_server.go +++ b/hld/daemon/http_server.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "net" "net/http" "time" @@ -90,17 +91,35 @@ func (s *HTTPServer) Start(ctx context.Context) error { // Register SSE endpoint directly (not part of strict interface) v1.GET("/stream/events", s.sseHandler.StreamEvents) - // Create HTTP server + // Create listener first to handle port 0 addr := fmt.Sprintf("%s:%d", s.config.HTTPHost, s.config.HTTPPort) + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", addr, err) + } + + // Get actual port after binding + actualAddr := listener.Addr().(*net.TCPAddr) + actualPort := actualAddr.Port + + // If port 0 was used, output actual port to stdout + if s.config.HTTPPort == 0 { + fmt.Printf("HTTP_PORT=%d\n", actualPort) + } + + slog.Info("Starting HTTP server", + "configured_port", s.config.HTTPPort, + "actual_address", actualAddr.String()) + + // Create HTTP server s.server = &http.Server{ - Addr: addr, Handler: s.router, } // Start server in goroutine go func() { - slog.Info("Starting HTTP server", "address", addr) - if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + // Use the existing listener + if err := s.server.Serve(listener); err != nil && err != http.ErrServerClosed { slog.Error("HTTP server error", "error", err) } }() diff --git a/humanlayer-wui/.gitignore b/humanlayer-wui/.gitignore index c063067..00575e6 100644 --- a/humanlayer-wui/.gitignore +++ b/humanlayer-wui/.gitignore @@ -25,3 +25,7 @@ dist-ssr # Generated Tauri config for local development src-tauri/tauri.conf.local.json + +# Bundled binaries (created during build) +src-tauri/bin/ +!src-tauri/bin/.gitkeep diff --git a/humanlayer-wui/README.md b/humanlayer-wui/README.md index 523ef42..32c7a0c 100644 --- a/humanlayer-wui/README.md +++ b/humanlayer-wui/README.md @@ -4,17 +4,106 @@ Web/desktop UI for the HumanLayer daemon (`hld`) built with Tauri and React. ## Development +### Running in Development Mode + +1. Build the daemon (required for auto-launch): + + ```bash + make daemon-dev-build + ``` + +2. Start CodeLayer in development mode: + ```bash + make codelayer-dev + ``` + +The daemon starts automatically and invisibly when the app launches. No manual daemon management needed. + +### Disabling Auto-Launch (Advanced Users) + +If you prefer to manage the daemon manually: + ```bash -# Install dependencies -bun install - -# Start dev server -bun run tauri dev - -# Build for production -bun run build +export HUMANLAYER_WUI_AUTOLAUNCH_DAEMON=false +make codelayer-dev ``` +### Using an External Daemon + +To connect to a daemon running on a specific port: + +```bash +export HUMANLAYER_DAEMON_HTTP_PORT=7777 +make codelayer-dev +``` + +### Building for Production + +To build CodeLayer with bundled daemon: + +```bash +make codelayer-bundle +``` + +This will: + +1. Build the daemon for macOS ARM64 +2. Build the humanlayer CLI for macOS ARM64 +3. Copy both to the Tauri resources +4. Build CodeLayer with the bundled binaries + +The resulting DMG will include both binaries and automatically manage their lifecycle. + +### Daemon Management + +The daemon lifecycle is completely automatic: + +**In development mode:** + +- Daemon starts invisibly when CodeLayer launches +- Each git branch gets its own daemon instance +- Database is copied from `daemon-dev.db` to `daemon-{branch}.db` +- Socket and port are isolated per branch +- Use debug panel (bottom-left settings icon) for manual control if needed + +**In production mode:** + +- Daemon starts invisibly when CodeLayer launches +- Uses default paths (`~/.humanlayer/daemon.db`) +- Stops automatically when the app exits +- No user interaction or awareness required + +**Error Handling:** + +- If daemon fails to start, app continues normally +- Connection can be established later via debug panel (dev) or automatically on retry +- All errors are logged but never interrupt the user experience + +### MCP Testing + +To test MCP functionality: + +**In development:** + +- Ensure you have `humanlayer` installed globally: `npm install -g humanlayer` +- Start CodeLayer: `make codelayer-dev` +- Configure Claude Code to use `humanlayer mcp claude_approvals` +- The MCP server will connect to your running daemon + +**In production (after Homebrew installation):** + +- Claude Code can directly execute `humanlayer mcp claude_approvals` +- No npm or npx required - Homebrew automatically created symlinks in PATH +- The MCP server connects to the daemon started by CodeLayer +- Verify PATH setup is working: `which humanlayer` should show `/usr/local/bin/humanlayer` + +**Troubleshooting MCP connection:** + +- If MCP can't find `humanlayer`, restart Claude Code after installation +- If launched from Dock, Claude Code may have limited PATH - launch from Terminal instead +- Check daemon is running: `ps aux | grep hld` +- Check MCP logs in Claude Code for connection errors + ## Quick Start for Frontend Development Always use React hooks, never the daemon client directly: diff --git a/humanlayer-wui/src-tauri/Cargo.lock b/humanlayer-wui/src-tauri/Cargo.lock index f6b2d22..d5138e4 100644 --- a/humanlayer-wui/src-tauri/Cargo.lock +++ b/humanlayer-wui/src-tauri/Cargo.lock @@ -510,8 +510,10 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -559,6 +561,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -582,9 +594,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.9.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -595,7 +607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.9.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -747,13 +759,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -764,7 +797,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.0", "windows-sys 0.60.2", ] @@ -871,7 +904,7 @@ dependencies = [ "rustc_version", "toml", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -880,6 +913,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.0" @@ -1007,6 +1049,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1014,7 +1065,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1028,6 +1079,12 @@ dependencies = [ "syn 2.0.103", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1454,6 +1511,25 @@ dependencies = [ "syn 2.0.103", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.9.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1502,6 +1578,17 @@ dependencies = [ "match_token", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.3.1" @@ -1513,6 +1600,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1520,7 +1618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.3.1", ] [[package]] @@ -1531,8 +1629,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "pin-project-lite", ] @@ -1542,10 +1640,20 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humanlayer-wui" version = "0.1.0" dependencies = [ + "chrono", + "dirs 5.0.1", + "regex", + "reqwest 0.11.27", "serde", "serde_json", "tauri", @@ -1554,10 +1662,37 @@ dependencies = [ "tauri-plugin-fs", "tauri-plugin-notification", "tauri-plugin-opener", + "tauri-plugin-shell", + "tauri-plugin-store", + "tokio", "tracing", "tracing-subscriber", ] +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.6.0" @@ -1567,8 +1702,8 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", @@ -1577,6 +1712,19 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "hyper-util" version = "0.1.14" @@ -1588,9 +1736,9 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", - "hyper", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.6.0", "ipnet", "libc", "percent-encoding", @@ -2147,6 +2295,23 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2513,6 +2678,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.103", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3042,6 +3251,17 @@ dependencies = [ "bitflags 2.9.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.0" @@ -3082,6 +3302,46 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg 0.50.0", +] + [[package]] name = "reqwest" version = "0.12.20" @@ -3092,10 +3352,10 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.6.0", "hyper-util", "js-sys", "log", @@ -3104,7 +3364,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-util", "tower", @@ -3158,6 +3418,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustversion" version = "1.0.21" @@ -3179,6 +3448,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3212,6 +3490,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3407,12 +3708,44 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.5" @@ -3474,7 +3807,7 @@ dependencies = [ "bytemuck", "cfg_aliases", "core-graphics", - "foreign-types", + "foreign-types 0.5.0", "js-sys", "log", "objc2 0.5.2", @@ -3589,6 +3922,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3609,6 +3948,27 @@ dependencies = [ "syn 2.0.103", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3629,7 +3989,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49c380ca75a231b87b6c9dd86948f035012e7171d1a7c40a9c2890489a7ffd8a" dependencies = [ "bitflags 2.9.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -3686,14 +4046,14 @@ checksum = "2f7a0f4019c80391d143ee26cd7cd1ed271ac241d3087d333f99f3269ba90812" dependencies = [ "anyhow", "bytes", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.2.16", "glob", "gtk", "heck 0.5.0", - "http", + "http 1.3.1", "jni", "libc", "log", @@ -3706,7 +4066,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.12.20", "serde", "serde_json", "serde_repr", @@ -3736,7 +4096,7 @@ checksum = "12f025c389d3adb83114bec704da973142e82fc6ec799c7c750c5e21cefaec83" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3886,6 +4246,43 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-shell" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b9ffadec5c3523f11e8273465cacb3d86ea7652a28e6e2a2e9b5c182f791d25" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "tokio", +] + +[[package]] +name = "tauri-plugin-store" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916c609664a56c82aeaefffca9851fd072d4d41f73d63f22ee3ee451508194f" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "tokio", + "tracing", +] + [[package]] name = "tauri-runtime" version = "2.7.0" @@ -3895,7 +4292,7 @@ dependencies = [ "cookie", "dpi", "gtk", - "http", + "http 1.3.1", "jni", "objc2 0.6.1", "objc2-ui-kit", @@ -3915,7 +4312,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe52ed0ef40fd7ad51a620ecb3018e32eba3040bb95025216a962a37f6f050c5" dependencies = [ "gtk", - "http", + "http 1.3.1", "jni", "log", "objc2 0.6.1", @@ -3948,7 +4345,7 @@ dependencies = [ "dunce", "glob", "html5ever", - "http", + "http 1.3.1", "infer", "json-patch", "kuchikiki", @@ -4131,11 +4528,35 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.52.0", ] +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.103", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.15" @@ -4221,7 +4642,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -4236,8 +4657,8 @@ dependencies = [ "bitflags 2.9.1", "bytes", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -4321,7 +4742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7eee98ec5c90daf179d55c20a49d8c0d043054ce7c26336c09a24d31f14fa0" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2 0.6.1", @@ -4485,6 +4906,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.0" @@ -4964,6 +5391,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5269,6 +5705,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" @@ -5328,7 +5774,7 @@ dependencies = [ "gdkx11", "gtk", "html5ever", - "http", + "http 1.3.1", "javascriptcore-rs", "jni", "kuchikiki", diff --git a/humanlayer-wui/src-tauri/Cargo.toml b/humanlayer-wui/src-tauri/Cargo.toml index b2cd3be..31a6617 100644 --- a/humanlayer-wui/src-tauri/Cargo.toml +++ b/humanlayer-wui/src-tauri/Cargo.toml @@ -23,7 +23,14 @@ tauri-plugin-opener = "2" tauri-plugin-notification = "2.3.0" tauri-plugin-fs = "2" tauri-plugin-clipboard-manager = "2" +tauri-plugin-shell = "2" +tauri-plugin-store = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = "0.1" tracing-subscriber = "0.3" +tokio = { version = "1", features = ["full"] } +dirs = "5.0" +regex = "1.10" +reqwest = { version = "0.11", features = ["blocking"] } +chrono = { version = "0.4", features = ["serde"] } diff --git a/humanlayer-wui/src-tauri/capabilities/default.json b/humanlayer-wui/src-tauri/capabilities/default.json index 4e1e1af..772b16e 100644 --- a/humanlayer-wui/src-tauri/capabilities/default.json +++ b/humanlayer-wui/src-tauri/capabilities/default.json @@ -18,6 +18,10 @@ ] }, "clipboard-manager:default", - "clipboard-manager:allow-write-text" + "clipboard-manager:allow-write-text", + "shell:allow-spawn", + "shell:allow-execute", + "shell:allow-kill", + "store:default" ] } diff --git a/humanlayer-wui/src-tauri/src/daemon.rs b/humanlayer-wui/src-tauri/src/daemon.rs new file mode 100644 index 0000000..3da2ceb --- /dev/null +++ b/humanlayer-wui/src-tauri/src/daemon.rs @@ -0,0 +1,488 @@ +use serde::{Deserialize, Serialize}; +use std::env; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Manager}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DaemonInfo { + pub port: u16, + pub pid: u32, + pub database_path: String, + pub socket_path: String, + pub branch_id: String, + pub is_running: bool, +} + +#[derive(Clone)] +pub struct DaemonManager { + process: Arc>>, + info: Arc>>, +} + +impl DaemonManager { + pub fn new() -> Self { + Self { + process: Arc::new(Mutex::new(None)), + info: Arc::new(Mutex::new(None)), + } + } + + pub async fn start_daemon( + &self, + app_handle: &AppHandle, + is_dev: bool, + branch_override: Option, + ) -> Result { + // Check if already running + { + let process = self.process.lock().unwrap(); + if process.is_some() { + if let Some(info) = self.info.lock().unwrap().as_ref() { + return Ok(info.clone()); + } + } + } + + // Check if daemon is already running on a specific port + if let Ok(port_str) = env::var("HUMANLAYER_DAEMON_HTTP_PORT") { + if let Ok(port) = port_str.parse::() { + // Try to connect to existing daemon + if check_daemon_health(port).await.is_ok() { + let info = DaemonInfo { + port, + pid: 0, // Unknown PID for external daemon + database_path: "external".to_string(), + socket_path: "external".to_string(), + branch_id: "external".to_string(), + is_running: true, + }; + *self.info.lock().unwrap() = Some(info.clone()); + return Ok(info); + } + } + } + + // Determine branch identifier + let branch_id = if is_dev { + branch_override + .or_else(get_git_branch) + .unwrap_or_else(|| "dev".to_string()) + } else { + "production".to_string() + }; + + // Extract ticket ID if present (e.g., "eng-1234" from "eng-1234-some-feature") + let branch_id = extract_ticket_id(&branch_id).unwrap_or(branch_id); + + // Set up paths + let home_dir = dirs::home_dir().ok_or("Failed to get home directory")?; + let humanlayer_dir = home_dir.join(".humanlayer"); + fs::create_dir_all(&humanlayer_dir) + .map_err(|e| format!("Failed to create .humanlayer directory: {e}"))?; + + // Database path + let database_path = if is_dev { + // Copy dev database if it doesn't exist + let dev_db = humanlayer_dir.join(format!("daemon-{branch_id}.db")); + if !dev_db.exists() { + let source_db = humanlayer_dir.join("daemon-dev.db"); + if source_db.exists() { + fs::copy(&source_db, &dev_db) + .map_err(|e| format!("Failed to copy dev database: {e}"))?; + } + } + dev_db + } else { + humanlayer_dir.join("daemon.db") + }; + + // Socket path + let socket_path = if is_dev { + humanlayer_dir.join(format!("daemon-{branch_id}.sock")) + } else { + humanlayer_dir.join("daemon.sock") + }; + + // Get daemon binary path (macOS only) + let daemon_path = get_daemon_path(app_handle, is_dev)?; + + // Build environment with port 0 for dynamic allocation + let mut env_vars = env::vars().collect::>(); + env_vars.push(( + "HUMANLAYER_DATABASE_PATH".to_string(), + database_path.to_str().unwrap().to_string(), + )); + env_vars.push(( + "HUMANLAYER_DAEMON_SOCKET".to_string(), + socket_path.to_str().unwrap().to_string(), + )); + env_vars.push(("HUMANLAYER_DAEMON_HTTP_PORT".to_string(), "0".to_string())); + env_vars.push(( + "HUMANLAYER_DAEMON_HTTP_HOST".to_string(), + "localhost".to_string(), + )); + + if is_dev { + env_vars.push(( + "HUMANLAYER_DAEMON_VERSION_OVERRIDE".to_string(), + branch_id.clone(), + )); + // Enable debug logging for daemon in dev mode + env_vars.push(( + "HUMANLAYER_DEBUG".to_string(), + "true".to_string(), + )); + env_vars.push(( + "GIN_MODE".to_string(), + "debug".to_string(), + )); + } + + // Start daemon with stdout capture and stderr logging + let mut cmd = Command::new(&daemon_path); + + // In dev mode, capture stderr to see what's happening + if is_dev { + cmd.envs(env_vars) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + } else { + cmd.envs(env_vars) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + } + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to start daemon: {e}"))?; + + // Get the PID before we do anything else + let pid = child.id(); + tracing::info!("Daemon spawned with PID: {}", pid); + + // If in dev mode, spawn a task to read stderr + if is_dev { + if let Some(stderr) = child.stderr.take() { + let branch_id_clone = branch_id.clone(); + tokio::spawn(async move { + let reader = BufReader::new(stderr); + for line in reader.lines() { + match line { + Ok(line) => { + // Parse slog format to extract level + let level = extract_log_level(&line); + let cleaned_line = remove_timestamp(&line); + + match level { + LogLevel::Error => tracing::error!("[Daemon] {}: {}", branch_id_clone, cleaned_line), + LogLevel::Warn => tracing::warn!("[Daemon] {}: {}", branch_id_clone, cleaned_line), + LogLevel::Info => tracing::info!("[Daemon] {}: {}", branch_id_clone, cleaned_line), + LogLevel::Debug => tracing::debug!("[Daemon] {}: {}", branch_id_clone, cleaned_line), + LogLevel::Trace => tracing::trace!("[Daemon] {}: {}", branch_id_clone, cleaned_line), + } + } + Err(e) => { + tracing::error!("Error reading daemon stderr: {}", e); + break; + } + } + } + }); + } + } + + // Parse stdout to get the actual port + let stdout = child + .stdout + .take() + .ok_or("Failed to capture daemon stdout")?; + + let mut reader = BufReader::new(stdout); + let mut actual_port = None; + let mut first_line = String::new(); + + // Read the first line synchronously to get the port + reader + .read_line(&mut first_line) + .map_err(|e| format!("Failed to read daemon stdout: {e}"))?; + + if first_line.starts_with("HTTP_PORT=") { + actual_port = first_line.trim().replace("HTTP_PORT=", "").parse::().ok(); + } + + let port = actual_port.ok_or("Daemon failed to report port")?; + tracing::info!("Got port {} from daemon stdout", port); + + // Now spawn a task to keep reading stdout to prevent SIGPIPE + let branch_id_for_stdout = branch_id.clone(); + tokio::spawn(async move { + // Continue reading the rest of stdout + for line in reader.lines() { + match line { + Ok(line) => { + tracing::trace!("[Daemon stdout] {}: {}", branch_id_for_stdout, line); + } + Err(e) => { + tracing::debug!("Daemon stdout closed for {}: {}", branch_id_for_stdout, e); + break; + } + } + } + tracing::debug!("Stdout reader task finished for {}", branch_id_for_stdout); + }); + + // Check if process is still alive after reading port + match child.try_wait() { + Ok(None) => tracing::info!("Daemon process still running after port read"), + Ok(Some(status)) => { + return Err(format!("Daemon process exited immediately after starting! Status: {status:?}")); + } + Err(e) => tracing::error!("Error checking daemon status: {}", e), + } + + let daemon_info = DaemonInfo { + port, + pid, + database_path: database_path.to_str().unwrap().to_string(), + socket_path: socket_path.to_str().unwrap().to_string(), + branch_id: branch_id.clone(), + is_running: true, + }; + + // Store the process and info before awaiting + { + let mut process = self.process.lock().unwrap(); + *process = Some(child); + } + *self.info.lock().unwrap() = Some(daemon_info.clone()); + + // Wait for daemon to be ready + tracing::info!("Waiting for daemon to be ready on port {}", port); + wait_for_daemon(port).await?; + tracing::info!("Daemon is ready and responding to health checks"); + + // Spawn a task to monitor the daemon process + let process_arc = self.process.clone(); + let branch_id_clone = branch_id.clone(); + let port_for_monitor = port; + tokio::spawn(async move { + let mut last_check = std::time::Instant::now(); + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + let mut process_guard = process_arc.lock().unwrap(); + if let Some(child) = process_guard.as_mut() { + match child.try_wait() { + Ok(Some(status)) => { + // Process has exited + tracing::error!( + "Daemon process exited unexpectedly! Branch: {}, Port: {}, Exit status: {:?}, Time since last check: {:?}", + branch_id_clone, + port_for_monitor, + status, + last_check.elapsed() + ); + // Clear the process + *process_guard = None; + break; + } + Ok(None) => { + // Still running + last_check = std::time::Instant::now(); + } + Err(e) => { + tracing::error!("Error checking daemon process status: {}", e); + break; + } + } + } else { + // No process to monitor + break; + } + } + }); + + Ok(daemon_info) + } + + pub fn stop_daemon(&self) -> Result<(), String> { + let mut process = self.process.lock().unwrap(); + + if let Some(mut child) = process.take() { + child + .kill() + .map_err(|e| format!("Failed to stop daemon: {e}"))?; + + // Wait for process to exit + let _ = child.wait(); + + // Update store to mark daemon as not running + if let Some(info) = self.info.lock().unwrap().as_mut() { + info.is_running = false; + } + } + + Ok(()) + } + + pub fn get_info(&self) -> Option { + self.info.lock().unwrap().clone() + } + + pub fn is_running(&self) -> bool { + let mut process = self.process.lock().unwrap(); + if let Some(child) = process.as_mut() { + // Check if process is still alive + match child.try_wait() { + Ok(None) => true, // Still running + _ => false, // Exited or error + } + } else { + false + } + } +} + +fn get_daemon_path(app_handle: &AppHandle, is_dev: bool) -> Result { + if is_dev { + // In dev mode, look for hld-dev in the project + let current = + env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?; + + // Handle both running from src-tauri and from humanlayer-wui + let dev_path = if current.ends_with("src-tauri") { + current + .parent() // humanlayer-wui + .and_then(|p| p.parent()) // humanlayer root + .ok_or("Failed to get parent directory")? + .join("hld") + .join("hld-dev") + } else { + current + .parent() // Go up from humanlayer-wui to humanlayer + .ok_or("Failed to get parent directory")? + .join("hld") + .join("hld-dev") + }; + + if dev_path.exists() { + Ok(dev_path) + } else { + Err(format!( + "Development daemon not found at {dev_path:?}. Run 'make daemon-dev-build' first." + )) + } + } else { + // In production, use bundled binary + let resource_dir = app_handle + .path() + .resource_dir() + .map_err(|e| format!("Failed to get resource directory: {e}"))?; + + Ok(resource_dir.join("bin").join("hld")) + } +} + +fn get_git_branch() -> Option { + Command::new("git") + .args(["branch", "--show-current"]) + .output() + .ok() + .and_then(|output| { + if output.status.success() { + String::from_utf8(output.stdout) + .ok() + .map(|s| s.trim().to_string()) + } else { + None + } + }) +} + +fn extract_ticket_id(branch: &str) -> Option { + // Extract patterns like "eng-1234" from branch names + let re = regex::Regex::new(r"(eng-\d+)").ok()?; + re.captures(branch) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) +} + +async fn check_daemon_health(port: u16) -> Result<(), String> { + let client = reqwest::Client::new(); + match client + .get(format!("http://localhost:{port}/api/v1/health")) + .send() + .await + { + Ok(response) if response.status().is_success() => Ok(()), + Ok(_) => Err("Daemon health check failed".to_string()), + Err(e) => Err(format!("Failed to connect to daemon: {e}")), + } +} + +async fn wait_for_daemon(port: u16) -> Result<(), String> { + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(10); + + while start.elapsed() < timeout { + if check_daemon_health(port).await.is_ok() { + return Ok(()); + } + + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + + Err("Daemon failed to start within 10 seconds".to_string()) +} + +#[derive(Debug)] +enum LogLevel { + Error, + Warn, + Info, + Debug, + Trace, +} + +fn extract_log_level(line: &str) -> LogLevel { + // slog format includes level like: "2024-01-30T10:15:30Z INFO message" + // or sometimes: "INFO[0001] message" for other loggers + + if line.contains(" ERROR ") || line.contains("ERROR[") { + LogLevel::Error + } else if line.contains(" WARN ") || line.contains("WARN[") { + LogLevel::Warn + } else if line.contains(" INFO ") || line.contains("INFO[") { + LogLevel::Info + } else if line.contains(" DEBUG ") || line.contains("DEBUG[") { + LogLevel::Debug + } else if line.contains(" TRACE ") || line.contains("TRACE[") { + LogLevel::Trace + } else { + // Default to info for unparseable lines + LogLevel::Info + } +} + +fn remove_timestamp(line: &str) -> &str { + // Remove slog timestamp to avoid double timestamps + // Format: "2024-01-30T10:15:30Z LEVEL message" + if let Some(idx) = line.find(" INFO ") { + &line[idx + 6..] + } else if let Some(idx) = line.find(" ERROR ") { + &line[idx + 7..] + } else if let Some(idx) = line.find(" WARN ") { + &line[idx + 6..] + } else if let Some(idx) = line.find(" DEBUG ") { + &line[idx + 7..] + } else if let Some(idx) = line.find(" TRACE ") { + &line[idx + 7..] + } else { + // Return original if no timestamp found + line + } +} diff --git a/humanlayer-wui/src-tauri/src/lib.rs b/humanlayer-wui/src-tauri/src/lib.rs index 95a94ed..de5bd5e 100644 --- a/humanlayer-wui/src-tauri/src/lib.rs +++ b/humanlayer-wui/src-tauri/src/lib.rs @@ -1,13 +1,221 @@ +mod daemon; + +use daemon::{DaemonInfo, DaemonManager}; +use std::env; +use std::path::PathBuf; +use tauri::{Manager, State}; +use tauri_plugin_store::StoreExt; + +// Helper to get store path based on dev mode and branch +fn get_store_path(is_dev: bool, branch_id: Option<&str>) -> PathBuf { + let home = dirs::home_dir().expect("Failed to get home directory"); + let humanlayer_dir = home.join(".humanlayer"); + + if is_dev { + // Use branch-specific store in dev mode + if let Some(branch) = branch_id { + humanlayer_dir.join(format!("codelayer-{branch}.json")) + } else { + humanlayer_dir.join("codelayer-dev.json") + } + } else { + humanlayer_dir.join("codelayer.json") + } +} + +#[tauri::command] +async fn start_daemon( + app_handle: tauri::AppHandle, + daemon_manager: State<'_, DaemonManager>, + is_dev: bool, + branch_override: Option, +) -> Result { + let info = daemon_manager + .start_daemon(&app_handle, is_dev, branch_override) + .await?; + + // Save to store using branch-specific path in dev mode + let store_path = get_store_path(is_dev, Some(&info.branch_id)); + let store = app_handle + .store(&store_path) + .map_err(|e| format!("Failed to access store: {e}"))?; + + store.set("current_daemon", serde_json::to_value(&info).unwrap()); + store + .save() + .map_err(|e| format!("Failed to save store: {e}"))?; + + Ok(info) +} + +#[tauri::command] +async fn stop_daemon( + app_handle: tauri::AppHandle, + daemon_manager: State<'_, DaemonManager>, +) -> Result<(), String> { + // Get daemon info before stopping to know which store to update + if let Some(info) = daemon_manager.get_info() { + let is_dev = info.branch_id != "production"; + let store_path = get_store_path(is_dev, Some(&info.branch_id)); + + // Stop the daemon + daemon_manager.stop_daemon()?; + + // Update store to mark as not running + let store = app_handle + .store(&store_path) + .map_err(|e| format!("Failed to access store: {e}"))?; + + // Get the stored daemon info and update is_running + if let Some(mut stored_info) = store + .get("current_daemon") + .and_then(|v| serde_json::from_value::(v).ok()) + { + stored_info.is_running = false; + store.set( + "current_daemon", + serde_json::to_value(&stored_info).unwrap(), + ); + store + .save() + .map_err(|e| format!("Failed to save store: {e}"))?; + } + } else { + daemon_manager.stop_daemon()?; + } + + Ok(()) +} + +#[tauri::command] +async fn get_daemon_info( + app_handle: tauri::AppHandle, + daemon_manager: State<'_, DaemonManager>, + is_dev: bool, +) -> Result, String> { + // First check if daemon manager has info + if let Some(info) = daemon_manager.get_info() { + return Ok(Some(info)); + } + + // Otherwise check store for last known daemon + let store_path = get_store_path(is_dev, None); + let store = app_handle + .store(&store_path) + .map_err(|e| format!("Failed to access store: {e}"))?; + + let stored_info = store + .get("current_daemon") + .and_then(|v| serde_json::from_value::(v).ok()); + + Ok(stored_info) +} + +#[tauri::command] +async fn is_daemon_running(daemon_manager: State<'_, DaemonManager>) -> Result { + Ok(daemon_manager.is_running()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Initialize tracing tracing_subscriber::fmt::init(); tauri::Builder::default() - .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_store::Builder::new().build()) + .setup(|app| { + let daemon_manager = DaemonManager::new(); + app.manage(daemon_manager.clone()); + + // Check if auto-launch is disabled + let should_autolaunch = env::var("HUMANLAYER_WUI_AUTOLAUNCH_DAEMON") + .map(|v| v.to_lowercase() != "false") + .unwrap_or(true); + + if should_autolaunch { + // Start daemon automatically + let app_handle_clone = app.app_handle().clone(); + tauri::async_runtime::spawn(async move { + let is_dev = cfg!(debug_assertions); + + // Small delay to ensure UI is ready + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + // Try to start daemon, but don't show errors in UI + match daemon_manager + .start_daemon(&app_handle_clone, is_dev, None) + .await + { + Ok(info) => { + tracing::info!("Daemon started automatically on port {}", info.port); + } + Err(e) => { + // Log error but don't interrupt user experience + tracing::error!("Failed to auto-start daemon: {}", e); + + // Common edge cases that shouldn't interrupt the user + if e.contains("port") || e.contains("already in use") { + tracing::info!("Port conflict detected, user can connect manually"); + } else if e.contains("binary not found") + || e.contains("daemon not found") + { + tracing::warn!( + "Daemon binary missing, this is expected in some dev scenarios" + ); + } else if e.contains("permission") { + tracing::error!( + "Permission issue starting daemon, user intervention required" + ); + } + } + } + }); + } else { + tracing::info!("Daemon auto-launch disabled by environment variable"); + } + + Ok(()) + }) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { .. } = event { + let daemon_manager = window.state::(); + + // Always stop daemon when window closes + if let Some(info) = daemon_manager.get_info() { + let is_dev = info.branch_id != "production"; + let store_path = get_store_path(is_dev, Some(&info.branch_id)); + + if let Ok(store) = window.app_handle().store(&store_path) { + // Update store to mark daemon as not running + if let Some(mut stored_info) = store + .get("current_daemon") + .and_then(|v| serde_json::from_value::(v).ok()) + { + stored_info.is_running = false; + store.set( + "current_daemon", + serde_json::to_value(&stored_info).unwrap(), + ); + let _ = store.save(); + } + } + + // Stop the daemon + let _ = daemon_manager.stop_daemon(); + } + } + }) + .invoke_handler(tauri::generate_handler![ + start_daemon, + stop_daemon, + get_daemon_info, + is_daemon_running, + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/humanlayer-wui/src-tauri/tauri.conf.json b/humanlayer-wui/src-tauri/tauri.conf.json index be6cec6..5b6c10f 100644 --- a/humanlayer-wui/src-tauri/tauri.conf.json +++ b/humanlayer-wui/src-tauri/tauri.conf.json @@ -31,7 +31,8 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "resources": ["bin/hld", "bin/humanlayer"] }, "plugins": { "fs": { diff --git a/humanlayer-wui/src/components/DebugPanel.tsx b/humanlayer-wui/src/components/DebugPanel.tsx new file mode 100644 index 0000000..2c6552c --- /dev/null +++ b/humanlayer-wui/src/components/DebugPanel.tsx @@ -0,0 +1,223 @@ +import { useState, useEffect } from 'react' +import { daemonService, type DaemonInfo } from '@/services/daemon-service' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Loader2, RefreshCw, Link, Server } from 'lucide-react' +import { useDaemonConnection } from '@/hooks/useDaemonConnection' + +interface DebugPanelProps { + open?: boolean + onOpenChange?: (open: boolean) => void +} + +export function DebugPanel({ open, onOpenChange }: DebugPanelProps) { + const { connected, reconnect } = useDaemonConnection() + const [isRestarting, setIsRestarting] = useState(false) + const [restartError, setRestartError] = useState(null) + const [customUrl, setCustomUrl] = useState('') + const [connectError, setConnectError] = useState(null) + const [daemonType, setDaemonType] = useState<'managed' | 'external'>('managed') + const [externalDaemonUrl, setExternalDaemonUrl] = useState(null) + const [daemonInfo, setDaemonInfo] = useState(null) + + // Only show in dev mode + if (!import.meta.env.DEV) { + return null + } + + useEffect(() => { + loadDaemonInfo() + }, [connected]) + + async function loadDaemonInfo() { + try { + const info = await daemonService.getDaemonInfo() + setDaemonInfo(info) + const type = daemonService.getDaemonType() + setDaemonType(type) + if (type === 'external') { + setExternalDaemonUrl((window as any).__HUMANLAYER_DAEMON_URL || null) + } + } catch (error) { + console.error('Failed to load daemon info:', error) + } + } + + async function handleRestartDaemon() { + setIsRestarting(true) + setRestartError(null) + + try { + // Stop current daemon + await daemonService.stopDaemon() + + // Wait a moment + await new Promise(resolve => setTimeout(resolve, 1000)) + + // Start new daemon (with rebuild in dev) + await daemonService.startDaemon(true) + + // Wait for it to be ready + await new Promise(resolve => setTimeout(resolve, 2000)) + + // Reconnect + await reconnect() + await loadDaemonInfo() + + setIsRestarting(false) + } catch (error: any) { + setRestartError(error.message || 'Failed to restart daemon') + setIsRestarting(false) + } + } + + async function handleConnectToCustom() { + setConnectError(null) + + try { + await daemonService.connectToExisting(customUrl) + await reconnect() + await loadDaemonInfo() + setCustomUrl('') + } catch (error: any) { + setConnectError(error.message || 'Failed to connect') + } + } + + async function handleSwitchToManaged() { + try { + await daemonService.switchToManagedDaemon() + await reconnect() + await loadDaemonInfo() + } catch (error: any) { + setConnectError(error.message || 'Failed to switch to managed daemon') + } + } + + return ( + + + + Debug Panel + Advanced daemon management for developers + +
+ + + Daemon Status + + +
+ Connection + + {connected ? 'Connected' : 'Disconnected'} + +
+ +
+ Daemon Type +
+ + {daemonType} +
+
+ + {daemonType === 'external' && externalDaemonUrl && ( +
+ External URL + {externalDaemonUrl} +
+ )} + + {daemonInfo && ( + <> +
+ Port + {daemonInfo.port} +
+
+ Branch + {daemonInfo.branch_id} +
+ + )} + + {daemonType === 'managed' ? ( + + ) : ( + + )} + + {restartError &&

{restartError}

} +
+
+ + + + Connect to Existing Daemon + + Connect to a daemon running on a custom URL + + + +
+ + setCustomUrl(e.target.value)} + /> +
+ + + + {connectError &&

{connectError}

} +
+
+
+
+
+ ) +} diff --git a/humanlayer-wui/src/components/Layout.tsx b/humanlayer-wui/src/components/Layout.tsx index 6311510..b6673fe 100644 --- a/humanlayer-wui/src/components/Layout.tsx +++ b/humanlayer-wui/src/components/Layout.tsx @@ -15,14 +15,16 @@ import { Toaster } from 'sonner' import { notificationService } from '@/services/NotificationService' import { useTheme } from '@/contexts/ThemeContext' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { MessageCircle } from 'lucide-react' +import { MessageCircle, Bug } from 'lucide-react' import { openUrl } from '@tauri-apps/plugin-opener' +import { DebugPanel } from '@/components/DebugPanel' import '@/App.css' export function Layout() { const [approvals, setApprovals] = useState([]) const [activeSessionId] = useState(null) const { setTheme } = useTheme() + const [isDebugPanelOpen, setIsDebugPanelOpen] = useState(false) // Use the daemon connection hook for all connection management const { connected, connecting, version, connect } = useDaemonConnection() @@ -240,6 +242,21 @@ export function Layout() {

Submit feedback (⌘⇧F)

+ {import.meta.env.DEV && ( + + + + + +

Debug Panel (Dev Only)

+
+
+ )}
@@ -264,6 +281,9 @@ export function Layout() { {/* Notifications */} + + {/* Debug Panel */} +
) } diff --git a/humanlayer-wui/src/hooks/useDaemonConnection.ts b/humanlayer-wui/src/hooks/useDaemonConnection.ts index edccbf4..33f94d5 100644 --- a/humanlayer-wui/src/hooks/useDaemonConnection.ts +++ b/humanlayer-wui/src/hooks/useDaemonConnection.ts @@ -1,6 +1,7 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { daemonClient } from '@/lib/daemon' import { formatError } from '@/utils/errors' +import { daemonService } from '@/services/daemon-service' interface UseDaemonConnectionReturn { connected: boolean @@ -8,6 +9,7 @@ interface UseDaemonConnectionReturn { error: string | null version: string | null connect: () => Promise + reconnect: () => Promise checkHealth: () => Promise } @@ -16,6 +18,7 @@ export function useDaemonConnection(): UseDaemonConnectionReturn { const [connecting, setConnecting] = useState(false) const [error, setError] = useState(null) const [version, setVersion] = useState(null) + const retryCount = useRef(0) const checkHealth = useCallback(async () => { try { @@ -31,11 +34,47 @@ export function useDaemonConnection(): UseDaemonConnectionReturn { }, []) const connect = useCallback(async () => { + if (connecting) return + + setConnecting(true) + setError(null) + + try { + await daemonClient.connect() + const health = await daemonClient.health() + + setConnected(true) + setVersion(health.version) + retryCount.current = 0 + } catch (err: any) { + setConnected(false) + + // Check if this is first failure and we have a managed daemon + if (retryCount.current === 0) { + const isManaged = await daemonService.isDaemonRunning() + if (!isManaged) { + // Let DaemonManager handle it + setError(formatError(err)) + } else { + // Managed daemon might be starting, retry + retryCount.current++ + setTimeout(() => connect(), 2000) + } + } else { + setError(formatError(err)) + } + } finally { + setConnecting(false) + } + }, []) + + const reconnect = useCallback(async () => { try { setConnecting(true) setError(null) + setConnected(false) - await daemonClient.connect() + await daemonClient.reconnect() await checkHealth() } catch (err) { setError(formatError(err)) @@ -64,6 +103,7 @@ export function useDaemonConnection(): UseDaemonConnectionReturn { error, version, connect, + reconnect, checkHealth, } } diff --git a/humanlayer-wui/src/lib/daemon/http-client.ts b/humanlayer-wui/src/lib/daemon/http-client.ts index b86b41c..f2a350c 100644 --- a/humanlayer-wui/src/lib/daemon/http-client.ts +++ b/humanlayer-wui/src/lib/daemon/http-client.ts @@ -61,7 +61,8 @@ export class HTTPDaemonClient implements IDaemonClient { } private async doConnect(): Promise { - const baseUrl = getDaemonUrl() + // getDaemonUrl now checks for managed daemon port dynamically + const baseUrl = await getDaemonUrl() this.client = new HLDClient({ baseUrl: `${baseUrl}/api/v1`, @@ -82,6 +83,16 @@ export class HTTPDaemonClient implements IDaemonClient { } } + async reconnect(): Promise { + // Disconnect first if connected + if (this.connected || this.connectionPromise) { + await this.disconnect() + } + + // Now connect to the potentially new URL + return this.connect() + } + async disconnect(): Promise { // Unsubscribe all event streams for (const unsubscribe of this.subscriptions.values()) { @@ -91,6 +102,8 @@ export class HTTPDaemonClient implements IDaemonClient { this.connected = false this.client = undefined + this.connectionPromise = undefined + this.retryCount = 0 } async health(): Promise { diff --git a/humanlayer-wui/src/lib/daemon/http-config.ts b/humanlayer-wui/src/lib/daemon/http-config.ts index b969850..1533d07 100644 --- a/humanlayer-wui/src/lib/daemon/http-config.ts +++ b/humanlayer-wui/src/lib/daemon/http-config.ts @@ -1,10 +1,27 @@ -// Get daemon URL from environment or default -export function getDaemonUrl(): string { - // Check for explicit URL first +import { daemonService } from '@/services/daemon-service' + +// Get daemon URL from environment or managed daemon +export async function getDaemonUrl(): Promise { + // Check for custom URL from debug panel first + if ((window as any).__HUMANLAYER_DAEMON_URL) { + return (window as any).__HUMANLAYER_DAEMON_URL + } + + // Check for explicit URL from environment if (import.meta.env.VITE_HUMANLAYER_DAEMON_URL) { return import.meta.env.VITE_HUMANLAYER_DAEMON_URL } + // Check if we have a managed daemon + try { + const daemonInfo = await daemonService.getDaemonInfo() + if (daemonInfo && daemonInfo.port) { + return `http://localhost:${daemonInfo.port}` + } + } catch (error) { + console.warn('Failed to get managed daemon info:', error) + } + // Check for port override const port = import.meta.env.VITE_HUMANLAYER_DAEMON_HTTP_PORT || '7777' const host = import.meta.env.VITE_HUMANLAYER_DAEMON_HTTP_HOST || 'localhost' @@ -15,7 +32,7 @@ export function getDaemonUrl(): string { // Headers to include with all requests export function getDefaultHeaders(): Record { return { - 'X-Client': 'wui', + 'X-Client': 'codelayer', 'X-Client-Version': import.meta.env.VITE_APP_VERSION || 'unknown', } } diff --git a/humanlayer-wui/src/lib/daemon/types.ts b/humanlayer-wui/src/lib/daemon/types.ts index 1e001bb..f06bb34 100644 --- a/humanlayer-wui/src/lib/daemon/types.ts +++ b/humanlayer-wui/src/lib/daemon/types.ts @@ -48,6 +48,7 @@ export interface SubscriptionHandle { // Client interface using legacy types for backward compatibility export interface DaemonClient { connect(): Promise + reconnect(): Promise disconnect(): Promise health(): Promise diff --git a/humanlayer-wui/src/services/daemon-service.ts b/humanlayer-wui/src/services/daemon-service.ts new file mode 100644 index 0000000..7fea00a --- /dev/null +++ b/humanlayer-wui/src/services/daemon-service.ts @@ -0,0 +1,104 @@ +import { invoke } from '@tauri-apps/api/core' + +export interface DaemonInfo { + port: number + pid: number + database_path: string + socket_path: string + branch_id: string +} + +export interface DaemonConfig { + branch_id: string + port: number + database_path: string + socket_path: string + last_used: string +} + +class DaemonService { + private static instance: DaemonService + + static getInstance(): DaemonService { + if (!DaemonService.instance) { + DaemonService.instance = new DaemonService() + } + return DaemonService.instance + } + + async startDaemon(isDev: boolean = false, branchOverride?: string): Promise { + try { + return await invoke('start_daemon', { isDev, branchOverride }) + } catch (error) { + throw new Error(`Failed to start daemon: ${error}`) + } + } + + async stopDaemon(): Promise { + try { + await invoke('stop_daemon') + } catch (error) { + throw new Error(`Failed to stop daemon: ${error}`) + } + } + + async getDaemonInfo(isDev: boolean = import.meta.env.DEV): Promise { + try { + return await invoke('get_daemon_info', { isDev }) + } catch (error) { + console.error('Failed to get daemon info:', error) + return null + } + } + + async isDaemonRunning(): Promise { + try { + return await invoke('is_daemon_running') + } catch (error) { + console.error('Failed to check daemon status:', error) + return false + } + } + + async getStoredConfigs(): Promise { + try { + return await invoke('get_stored_configs') + } catch (error) { + console.error('Failed to get stored configs:', error) + return [] + } + } + + async getConfigPath(): Promise { + try { + return await invoke('get_config_path') + } catch { + return 'unknown' + } + } + + async connectToExisting(url: string): Promise { + // Validate URL format + try { + new URL(url) + } catch { + throw new Error(`Invalid URL format: ${url}`) + } + + // Store the custom URL temporarily (not in Tauri store) + ;(window as any).__HUMANLAYER_DAEMON_URL = url + ;(window as any).__HUMANLAYER_DAEMON_TYPE = 'external' + } + + async switchToManagedDaemon(): Promise { + // Clear the custom URL to switch back to managed daemon + delete (window as any).__HUMANLAYER_DAEMON_URL + delete (window as any).__HUMANLAYER_DAEMON_TYPE + } + + getDaemonType(): 'managed' | 'external' { + return (window as any).__HUMANLAYER_DAEMON_TYPE || 'managed' + } +} + +export const daemonService = DaemonService.getInstance()