Merge remote-tracking branch 'allison/allison/eng-1784-bundle-daemon-and-cli-with-wui-for-single-installation' into dexter/eng-1784-bundle-daemon-and-cli-with-wui-for-single-installation

This commit is contained in:
dexhorthy
2025-07-30 17:07:36 -07:00
19 changed files with 1801 additions and 66 deletions

View File

@@ -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:

View File

@@ -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:

View File

@@ -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 {
// 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)
}
httpServer := NewHTTPServer(cfg, sessionManager, approvalManager, conversationStore, eventBus)
return &Daemon{
config: cfg,

View File

@@ -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)
}
}()

View File

@@ -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

View File

@@ -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:

View File

@@ -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",

View File

@@ -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"] }

View File

@@ -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"
]
}

View File

@@ -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<Mutex<Option<Child>>>,
info: Arc<Mutex<Option<DaemonInfo>>>,
}
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<String>,
) -> Result<DaemonInfo, String> {
// 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::<u16>() {
// 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::<Vec<_>>();
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::<u16>().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<DaemonInfo> {
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<PathBuf, String> {
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<String> {
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<String> {
// 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
}
}

View File

@@ -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<String>,
) -> Result<DaemonInfo, String> {
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::<DaemonInfo>(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<Option<DaemonInfo>, 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::<DaemonInfo>(v).ok());
Ok(stored_info)
}
#[tauri::command]
async fn is_daemon_running(daemon_manager: State<'_, DaemonManager>) -> Result<bool, String> {
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::<DaemonManager>();
// 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::<DaemonInfo>(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");
}

View File

@@ -31,7 +31,8 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"resources": ["bin/hld", "bin/humanlayer"]
},
"plugins": {
"fs": {

View File

@@ -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<string | null>(null)
const [customUrl, setCustomUrl] = useState('')
const [connectError, setConnectError] = useState<string | null>(null)
const [daemonType, setDaemonType] = useState<'managed' | 'external'>('managed')
const [externalDaemonUrl, setExternalDaemonUrl] = useState<string | null>(null)
const [daemonInfo, setDaemonInfo] = useState<DaemonInfo | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Debug Panel</DialogTitle>
<DialogDescription>Advanced daemon management for developers</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">Daemon Status</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm">Connection</span>
<span
className={`text-sm font-medium ${connected ? 'text-[var(--terminal-success)]' : 'text-[var(--terminal-error)]'}`}
>
{connected ? 'Connected' : 'Disconnected'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Daemon Type</span>
<div className="flex items-center gap-2">
<Server className="h-3 w-3 text-muted-foreground" />
<span className="text-sm font-medium capitalize">{daemonType}</span>
</div>
</div>
{daemonType === 'external' && externalDaemonUrl && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">External URL</span>
<span className="text-sm font-mono">{externalDaemonUrl}</span>
</div>
)}
{daemonInfo && (
<>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Port</span>
<span className="text-sm font-mono">{daemonInfo.port}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Branch</span>
<span className="text-sm font-mono">{daemonInfo.branch_id}</span>
</div>
</>
)}
{daemonType === 'managed' ? (
<Button
onClick={handleRestartDaemon}
disabled={isRestarting}
className="w-full"
variant="outline"
>
{isRestarting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Restarting Daemon...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Restart Daemon
</>
)}
</Button>
) : (
<Button onClick={handleSwitchToManaged} className="w-full" variant="outline">
Switch to Managed Daemon
</Button>
)}
{restartError && <p className="text-sm text-destructive">{restartError}</p>}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Connect to Existing Daemon</CardTitle>
<CardDescription className="text-xs">
Connect to a daemon running on a custom URL
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="space-y-1">
<Label htmlFor="url" className="text-sm">
Daemon URL
</Label>
<Input
id="url"
type="text"
placeholder="http://127.0.0.1:7777"
value={customUrl}
onChange={e => setCustomUrl(e.target.value)}
/>
</div>
<Button
onClick={handleConnectToCustom}
disabled={!customUrl}
className="w-full"
variant="outline"
>
<Link className="mr-2 h-4 w-4" />
Connect
</Button>
{connectError && <p className="text-sm text-destructive">{connectError}</p>}
</CardContent>
</Card>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -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<any[]>([])
const [activeSessionId] = useState<string | null>(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() {
<p>Submit feedback (F)</p>
</TooltipContent>
</Tooltip>
{import.meta.env.DEV && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setIsDebugPanelOpen(true)}
className="px-1.5 py-0.5 text-xs font-mono border border-border bg-background text-foreground hover:bg-accent/10 transition-colors"
>
<Bug className="w-3 h-3" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Debug Panel (Dev Only)</p>
</TooltipContent>
</Tooltip>
)}
<ThemeSelector />
<div className="flex items-center gap-2 font-mono text-xs">
<span className="uppercase tracking-wider">
@@ -264,6 +281,9 @@ export function Layout() {
{/* Notifications */}
<Toaster position="bottom-right" richColors />
{/* Debug Panel */}
<DebugPanel open={isDebugPanelOpen} onOpenChange={setIsDebugPanelOpen} />
</div>
)
}

View File

@@ -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<void>
reconnect: () => Promise<void>
checkHealth: () => Promise<void>
}
@@ -16,6 +18,7 @@ export function useDaemonConnection(): UseDaemonConnectionReturn {
const [connecting, setConnecting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [version, setVersion] = useState<string | null>(null)
const retryCount = useRef(0)
const checkHealth = useCallback(async () => {
try {
@@ -31,11 +34,47 @@ export function useDaemonConnection(): UseDaemonConnectionReturn {
}, [])
const connect = useCallback(async () => {
try {
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.reconnect()
await checkHealth()
} catch (err) {
setError(formatError(err))
@@ -64,6 +103,7 @@ export function useDaemonConnection(): UseDaemonConnectionReturn {
error,
version,
connect,
reconnect,
checkHealth,
}
}

View File

@@ -61,7 +61,8 @@ export class HTTPDaemonClient implements IDaemonClient {
}
private async doConnect(): Promise<void> {
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<void> {
// 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<void> {
// 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<HealthCheckResponse> {

View File

@@ -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<string> {
// 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<string, string> {
return {
'X-Client': 'wui',
'X-Client': 'codelayer',
'X-Client-Version': import.meta.env.VITE_APP_VERSION || 'unknown',
}
}

View File

@@ -48,6 +48,7 @@ export interface SubscriptionHandle {
// Client interface using legacy types for backward compatibility
export interface DaemonClient {
connect(): Promise<void>
reconnect(): Promise<void>
disconnect(): Promise<void>
health(): Promise<HealthCheckResponse>

View File

@@ -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<DaemonInfo> {
try {
return await invoke<DaemonInfo>('start_daemon', { isDev, branchOverride })
} catch (error) {
throw new Error(`Failed to start daemon: ${error}`)
}
}
async stopDaemon(): Promise<void> {
try {
await invoke('stop_daemon')
} catch (error) {
throw new Error(`Failed to stop daemon: ${error}`)
}
}
async getDaemonInfo(isDev: boolean = import.meta.env.DEV): Promise<DaemonInfo | null> {
try {
return await invoke<DaemonInfo | null>('get_daemon_info', { isDev })
} catch (error) {
console.error('Failed to get daemon info:', error)
return null
}
}
async isDaemonRunning(): Promise<boolean> {
try {
return await invoke<boolean>('is_daemon_running')
} catch (error) {
console.error('Failed to check daemon status:', error)
return false
}
}
async getStoredConfigs(): Promise<DaemonConfig[]> {
try {
return await invoke<DaemonConfig[]>('get_stored_configs')
} catch (error) {
console.error('Failed to get stored configs:', error)
return []
}
}
async getConfigPath(): Promise<string> {
try {
return await invoke<string>('get_config_path')
} catch {
return 'unknown'
}
}
async connectToExisting(url: string): Promise<void> {
// 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<void> {
// 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()