From 8b17a175c3e7e27b789812eba4e3cd760beadb10 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 6 Jan 2026 04:08:11 +0000 Subject: Initial Control system --- .makima/orchestrate.sh | 206 ++ Cargo.lock | 551 +++- Cargo.toml | 2 +- makima/Cargo.lock | 3022 -------------------- makima/Cargo.toml | 12 + makima/daemon/Cargo.toml | 48 + makima/daemon/README.md | 353 +++ makima/daemon/src/cli.rs | 45 + makima/daemon/src/config.rs | 536 ++++ makima/daemon/src/db/local.rs | 391 +++ makima/daemon/src/db/mod.rs | 5 + makima/daemon/src/error.rs | 75 + makima/daemon/src/lib.rs | 21 + makima/daemon/src/main.rs | 313 ++ makima/daemon/src/process/claude.rs | 481 ++++ makima/daemon/src/process/claude_protocol.rs | 59 + makima/daemon/src/process/mod.rs | 10 + makima/daemon/src/task/manager.rs | 2248 +++++++++++++++ makima/daemon/src/task/mod.rs | 7 + makima/daemon/src/task/state.rs | 161 ++ makima/daemon/src/temp.rs | 224 ++ makima/daemon/src/worktree/manager.rs | 1623 +++++++++++ makima/daemon/src/worktree/mod.rs | 11 + makima/daemon/src/ws/client.rs | 290 ++ makima/daemon/src/ws/mod.rs | 7 + makima/daemon/src/ws/protocol.rs | 511 ++++ makima/frontend/package-lock.json | 134 + makima/frontend/package.json | 1 + makima/frontend/pnpm-lock.yaml | 101 + makima/frontend/src/components/Masthead.tsx | 10 +- makima/frontend/src/components/NavStrip.tsx | 33 +- makima/frontend/src/components/ProtectedRoute.tsx | 26 + .../frontend/src/components/files/BodyRenderer.tsx | 121 +- makima/frontend/src/components/files/CliInput.tsx | 58 +- .../src/components/files/ElementContextMenu.tsx | 292 ++ .../frontend/src/components/files/FileDetail.tsx | 59 + makima/frontend/src/components/files/FileList.tsx | 207 +- .../src/components/mesh/DirectoryInput.tsx | 220 ++ .../src/components/mesh/InlineSubtaskEditor.tsx | 262 ++ .../src/components/mesh/MergeConflictResolver.tsx | 504 ++++ .../src/components/mesh/OverlayDiffViewer.tsx | 476 +++ makima/frontend/src/components/mesh/PRPreview.tsx | 314 ++ .../frontend/src/components/mesh/SubtaskTree.tsx | 297 ++ makima/frontend/src/components/mesh/TaskDetail.tsx | 886 ++++++ makima/frontend/src/components/mesh/TaskList.tsx | 164 ++ makima/frontend/src/components/mesh/TaskOutput.tsx | 281 ++ .../src/components/mesh/UnifiedMeshChatInput.tsx | 536 ++++ makima/frontend/src/contexts/AuthContext.tsx | 160 ++ makima/frontend/src/hooks/useMeshChatHistory.ts | 133 + makima/frontend/src/hooks/useTaskSubscription.ts | 333 +++ makima/frontend/src/hooks/useTasks.ts | 130 + makima/frontend/src/lib/api.ts | 921 +++++- makima/frontend/src/lib/supabase.ts | 26 + makima/frontend/src/main.tsx | 71 +- makima/frontend/src/routes/_index.tsx | 12 +- makima/frontend/src/routes/files.tsx | 350 ++- makima/frontend/src/routes/login.tsx | 150 + makima/frontend/src/routes/mesh.tsx | 634 ++++ makima/frontend/src/routes/settings.tsx | 724 +++++ makima/frontend/tsconfig.tsbuildinfo | 2 +- .../20250102000000_create_mesh_tables.sql | 83 + .../20250104000000_create_mesh_chat_history.sql | 34 + .../migrations/20250106000000_add_task_depth.sql | 21 + .../20250107000000_simplify_task_depth.sql | 18 + .../20250108000000_add_completion_actions.sql | 13 + .../20250109000000_add_continue_from_task_id.sql | 11 + .../20250110000000_create_owners_table.sql | 25 + .../20250110000001_create_users_table.sql | 27 + .../20250110000002_create_groups_tables.sql | 53 + .../20250110000003_create_api_keys_table.sql | 30 + .../20250110000004_create_api_key_events_table.sql | 20 + .../20250110000005_create_placeholder_owners.sql | 18 + .../20250110000006_add_owner_foreign_keys.sql | 30 + .../20250110000007_create_auth_trigger.sql | 63 + .../20250110000008_remove_owner_defaults.sql | 46 + makima/src/db/models.rs | 589 ++++ makima/src/db/repository.rs | 1393 ++++++++- makima/src/llm/mesh_tools.rs | 1080 +++++++ makima/src/llm/mod.rs | 2 + makima/src/llm/tools.rs | 206 +- makima/src/server/auth.rs | 1238 ++++++++ makima/src/server/handlers/api_keys.rs | 282 ++ makima/src/server/handlers/chat.rs | 115 +- makima/src/server/handlers/files.rs | 53 +- makima/src/server/handlers/mesh.rs | 1679 +++++++++++ makima/src/server/handlers/mesh_chat.rs | 2088 ++++++++++++++ makima/src/server/handlers/mesh_daemon.rs | 959 +++++++ makima/src/server/handlers/mesh_merge.rs | 441 +++ makima/src/server/handlers/mesh_ws.rs | 346 +++ makima/src/server/handlers/mod.rs | 7 + makima/src/server/handlers/users.rs | 972 +++++++ makima/src/server/mod.rs | 59 +- makima/src/server/openapi.rs | 96 +- makima/src/server/state.rs | 467 ++- 94 files changed, 29269 insertions(+), 3135 deletions(-) create mode 100755 .makima/orchestrate.sh delete mode 100644 makima/Cargo.lock create mode 100644 makima/daemon/Cargo.toml create mode 100644 makima/daemon/README.md create mode 100644 makima/daemon/src/cli.rs create mode 100644 makima/daemon/src/config.rs create mode 100644 makima/daemon/src/db/local.rs create mode 100644 makima/daemon/src/db/mod.rs create mode 100644 makima/daemon/src/error.rs create mode 100644 makima/daemon/src/lib.rs create mode 100644 makima/daemon/src/main.rs create mode 100644 makima/daemon/src/process/claude.rs create mode 100644 makima/daemon/src/process/claude_protocol.rs create mode 100644 makima/daemon/src/process/mod.rs create mode 100644 makima/daemon/src/task/manager.rs create mode 100644 makima/daemon/src/task/mod.rs create mode 100644 makima/daemon/src/task/state.rs create mode 100644 makima/daemon/src/temp.rs create mode 100644 makima/daemon/src/worktree/manager.rs create mode 100644 makima/daemon/src/worktree/mod.rs create mode 100644 makima/daemon/src/ws/client.rs create mode 100644 makima/daemon/src/ws/mod.rs create mode 100644 makima/daemon/src/ws/protocol.rs create mode 100644 makima/frontend/src/components/ProtectedRoute.tsx create mode 100644 makima/frontend/src/components/files/ElementContextMenu.tsx create mode 100644 makima/frontend/src/components/mesh/DirectoryInput.tsx create mode 100644 makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx create mode 100644 makima/frontend/src/components/mesh/MergeConflictResolver.tsx create mode 100644 makima/frontend/src/components/mesh/OverlayDiffViewer.tsx create mode 100644 makima/frontend/src/components/mesh/PRPreview.tsx create mode 100644 makima/frontend/src/components/mesh/SubtaskTree.tsx create mode 100644 makima/frontend/src/components/mesh/TaskDetail.tsx create mode 100644 makima/frontend/src/components/mesh/TaskList.tsx create mode 100644 makima/frontend/src/components/mesh/TaskOutput.tsx create mode 100644 makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx create mode 100644 makima/frontend/src/contexts/AuthContext.tsx create mode 100644 makima/frontend/src/hooks/useMeshChatHistory.ts create mode 100644 makima/frontend/src/hooks/useTaskSubscription.ts create mode 100644 makima/frontend/src/hooks/useTasks.ts create mode 100644 makima/frontend/src/lib/supabase.ts create mode 100644 makima/frontend/src/routes/login.tsx create mode 100644 makima/frontend/src/routes/mesh.tsx create mode 100644 makima/frontend/src/routes/settings.tsx create mode 100644 makima/migrations/20250102000000_create_mesh_tables.sql create mode 100644 makima/migrations/20250104000000_create_mesh_chat_history.sql create mode 100644 makima/migrations/20250106000000_add_task_depth.sql create mode 100644 makima/migrations/20250107000000_simplify_task_depth.sql create mode 100644 makima/migrations/20250108000000_add_completion_actions.sql create mode 100644 makima/migrations/20250109000000_add_continue_from_task_id.sql create mode 100644 makima/migrations/20250110000000_create_owners_table.sql create mode 100644 makima/migrations/20250110000001_create_users_table.sql create mode 100644 makima/migrations/20250110000002_create_groups_tables.sql create mode 100644 makima/migrations/20250110000003_create_api_keys_table.sql create mode 100644 makima/migrations/20250110000004_create_api_key_events_table.sql create mode 100644 makima/migrations/20250110000005_create_placeholder_owners.sql create mode 100644 makima/migrations/20250110000006_add_owner_foreign_keys.sql create mode 100644 makima/migrations/20250110000007_create_auth_trigger.sql create mode 100644 makima/migrations/20250110000008_remove_owner_defaults.sql create mode 100644 makima/src/llm/mesh_tools.rs create mode 100644 makima/src/server/auth.rs create mode 100644 makima/src/server/handlers/api_keys.rs create mode 100644 makima/src/server/handlers/mesh.rs create mode 100644 makima/src/server/handlers/mesh_chat.rs create mode 100644 makima/src/server/handlers/mesh_daemon.rs create mode 100644 makima/src/server/handlers/mesh_merge.rs create mode 100644 makima/src/server/handlers/mesh_ws.rs create mode 100644 makima/src/server/handlers/users.rs diff --git a/.makima/orchestrate.sh b/.makima/orchestrate.sh new file mode 100755 index 0000000..15095bd --- /dev/null +++ b/.makima/orchestrate.sh @@ -0,0 +1,206 @@ +#!/bin/bash +# Makima Orchestrator Helper Script +# Usage: ./orchestrate.sh [args...] + +API_URL="${MAKIMA_API_URL:-http://localhost:8080}" +API_KEY="${MAKIMA_API_KEY}" +TASK_ID="${MAKIMA_TASK_ID}" + +if [ -z "$API_KEY" ]; then + echo "Error: MAKIMA_API_KEY not set" >&2 + exit 1 +fi + +if [ -z "$TASK_ID" ]; then + echo "Error: MAKIMA_TASK_ID not set" >&2 + exit 1 +fi + +# Helper function to make API calls and check for errors +api_call() { + local method="$1" + local url="$2" + local data="$3" + local response + local http_code + + if [ -n "$data" ]; then + response=$(curl -s -w "\n%{http_code}" -X "$method" \ + -H "X-Makima-Tool-Key: $API_KEY" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "$url") + else + response=$(curl -s -w "\n%{http_code}" -X "$method" \ + -H "X-Makima-Tool-Key: $API_KEY" \ + "$url") + fi + + # Extract HTTP code (last line) and body (everything else) + http_code=$(echo "$response" | tail -n1) + body=$(echo "$response" | sed '$d') + + # Check for curl errors or non-2xx status + if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then + echo "Error: API request failed with HTTP $http_code" >&2 + echo "URL: $url" >&2 + echo "Response: $body" >&2 + echo "$body" + return 1 + fi + + echo "$body" + return 0 +} + +case "$1" in + list) + api_call GET "$API_URL/api/v1/mesh/tasks/$TASK_ID/subtasks" + ;; + create) + # Parse arguments: create "name" "plan" [--continue-from ] [--files "file1,file2"] + if [ -z "$2" ] || [ -z "$3" ]; then + echo "Usage: $0 create \"\" \"\" [--continue-from ] [--files \"file1,file2\"]" >&2 + exit 1 + fi + NAME="$2" + PLAN="$3" + CONTINUE_FROM="" + COPY_FILES="" + + # Parse optional flags (can be in any order after name and plan) + shift 3 + while [ $# -gt 0 ]; do + case "$1" in + --continue-from) + CONTINUE_FROM="$2" + shift 2 + ;; + --files) + COPY_FILES="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac + done + + # Escape quotes in name and plan for JSON + NAME_ESCAPED=$(echo "$NAME" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g') + PLAN_ESCAPED=$(echo "$PLAN" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g') + + # Build JSON body + JSON_BODY="{\"name\":\"$NAME_ESCAPED\",\"plan\":\"$PLAN_ESCAPED\",\"parentTaskId\":\"$TASK_ID\"" + + if [ -n "$CONTINUE_FROM" ]; then + echo "Creating subtask: $NAME (continuing from $CONTINUE_FROM)..." >&2 + JSON_BODY="$JSON_BODY,\"continueFromTaskId\":\"$CONTINUE_FROM\"" + else + echo "Creating subtask: $NAME..." >&2 + fi + + if [ -n "$COPY_FILES" ]; then + # Convert comma-separated file list to JSON array + FILES_JSON="[" + first=true + IFS=',' read -ra FILE_ARRAY <<< "$COPY_FILES" + for file in "${FILE_ARRAY[@]}"; do + file=$(echo "$file" | xargs) # trim whitespace + if [ "$first" = true ]; then + FILES_JSON="$FILES_JSON\"$file\"" + first=false + else + FILES_JSON="$FILES_JSON,\"$file\"" + fi + done + FILES_JSON="$FILES_JSON]" + JSON_BODY="$JSON_BODY,\"copyFiles\":$FILES_JSON" + echo " (copying files: $COPY_FILES)" >&2 + fi + + JSON_BODY="$JSON_BODY}" + api_call POST "$API_URL/api/v1/mesh/tasks" "$JSON_BODY" + ;; + start) + if [ -z "$2" ]; then + echo "Usage: $0 start " >&2 + exit 1 + fi + echo "Starting subtask $2..." >&2 + api_call POST "$API_URL/api/v1/mesh/tasks/$2/start" + ;; + stop) + if [ -z "$2" ]; then + echo "Usage: $0 stop " >&2 + exit 1 + fi + api_call POST "$API_URL/api/v1/mesh/tasks/$2/stop" + ;; + status) + if [ -z "$2" ]; then + echo "Usage: $0 status " >&2 + exit 1 + fi + api_call GET "$API_URL/api/v1/mesh/tasks/$2" + ;; + output) + if [ -z "$2" ]; then + echo "Usage: $0 output " >&2 + exit 1 + fi + api_call GET "$API_URL/api/v1/mesh/tasks/$2/output" + ;; + worktree) + if [ -z "$2" ]; then + echo "Usage: $0 worktree " >&2 + exit 1 + fi + # Get the worktree path from the task's overlayPath field via API + TASK_JSON=$(api_call GET "$API_URL/api/v1/mesh/tasks/$2") + if [ $? -ne 0 ]; then + echo "Error: Failed to get task info" >&2 + exit 1 + fi + WORKTREE_PATH=$(echo "$TASK_JSON" | grep -o '"overlayPath":"[^"]*"' | cut -d'"' -f4) + if [ -z "$WORKTREE_PATH" ]; then + echo "Error: Task has no worktree path (may not have started yet)" >&2 + exit 1 + fi + if [ -d "$WORKTREE_PATH" ]; then + echo "$WORKTREE_PATH" + else + echo "Error: Worktree not found at $WORKTREE_PATH" >&2 + echo "The worktree may have been cleaned up." >&2 + exit 1 + fi + ;; + done) + SUMMARY="${2:-Task completed}" + api_call PUT "$API_URL/api/v1/mesh/tasks/$TASK_ID" "{\"status\":\"done\",\"progressSummary\":\"$SUMMARY\"}" + ;; + *) + echo "Makima Orchestrator Helper" + echo "" + echo "Usage: $0 [args...]" + echo "" + echo "Subtask Commands:" + echo " list List all subtasks and their status" + echo " create \"\" \"\" Create a new subtask" + echo " create \"...\" --continue-from ID Create subtask continuing from another task's worktree" + echo " create \"...\" --files \"file1,file2\" Copy specific files from parent (orchestrator) worktree" + echo " start Start a subtask" + echo " stop Stop a running subtask" + echo " status Get detailed subtask status" + echo " output Get subtask output history" + echo " worktree Get path to subtask's worktree" + echo "" + echo "Completion:" + echo " done [summary] Mark orchestrator as complete" + echo "" + echo "Examples:" + echo " create \"Fix bug\" \"Fix the null check bug\" --files \"PLAN.md\"" + echo " create \"Step 2\" \"Continue work\" --continue-from abc123 --files \"shared.rs,types.rs\"" + ;; +esac diff --git a/Cargo.lock b/Cargo.lock index c1f237b..63ac8ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,12 +111,29 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atoi" version = "2.0.0" @@ -194,12 +211,32 @@ dependencies = [ "tracing", ] +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "futures-core", + "getrandom 0.2.16", + "instant", + "pin-project-lite", + "rand 0.8.5", + "tokio", +] + [[package]] name = "base64" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -378,6 +415,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "config" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" +dependencies = [ + "async-trait", + "convert_case", + "json5", + "nom", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml", + "yaml-rust2", +] + [[package]] name = "console" version = "0.15.11" @@ -397,6 +453,35 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -480,6 +565,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -534,6 +625,20 @@ dependencies = [ "serde", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.9.0" @@ -551,6 +656,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -605,13 +719,34 @@ dependencies = [ "subtle", ] +[[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]] @@ -622,7 +757,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -637,6 +772,15 @@ dependencies = [ "syn", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -736,6 +880,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -935,8 +1091,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1003,6 +1161,24 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hashlink" version = "0.10.0" @@ -1036,7 +1212,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" dependencies = [ - "dirs", + "dirs 6.0.0", "futures", "http", "indicatif", @@ -1087,6 +1263,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "hound" version = "3.5.1" @@ -1382,6 +1569,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1507,6 +1703,32 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1545,6 +1767,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ + "cc", "pkg-config", "vcpkg", ] @@ -1608,22 +1831,28 @@ dependencies = [ "ahash", "anyhow", "axum", + "base64 0.22.1", "bytes", "chrono", + "dashmap", "futures", + "hex", "hf-hub", "indexmap", "jaq-core", "jaq-interpret", "jaq-parse", "jaq-std", + "jsonwebtoken", "ndarray", "once_cell", "ort", "parakeet-rs", + "rand 0.8.5", "reqwest", "serde", "serde_json", + "sha2", "sqlx", "symphonia", "thiserror 2.0.17", @@ -1637,6 +1866,35 @@ dependencies = [ "uuid", ] +[[package]] +name = "makima-daemon" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "backoff", + "chrono", + "clap", + "config", + "dashmap", + "dirs 5.0.1", + "futures", + "hex", + "hostname", + "rand 0.9.2", + "rusqlite", + "serde", + "serde_json", + "sha2", + "shell-escape", + "thiserror 2.0.17", + "tokio", + "tokio-tungstenite 0.24.0", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1811,6 +2069,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1836,6 +2104,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.46" @@ -1966,6 +2240,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ort" version = "2.0.0-rc.10" @@ -2041,6 +2325,22 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2056,6 +2356,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2119,6 +2462,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2286,6 +2635,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[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.2" @@ -2383,6 +2743,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64 0.21.7", + "bitflags 2.10.0", + "serde", + "serde_derive", +] + [[package]] name = "rsa" version = "0.9.9" @@ -2403,6 +2775,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.9.1", + "libsqlite3-sys", + "smallvec 1.15.1", +] + [[package]] name = "rust-embed" version = "8.9.0" @@ -2437,6 +2823,16 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rust-ini" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustfft" version = "6.4.1" @@ -2612,6 +3008,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2655,6 +3060,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-escape" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f" + [[package]] name = "shlex" version = "1.3.0" @@ -2686,6 +3097,18 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.17", + "time", +] + [[package]] name = "slab" version = "0.4.11" @@ -2790,7 +3213,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.15.5", - "hashlink", + "hashlink 0.10.0", "indexmap", "log", "memchr", @@ -3294,6 +3717,46 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -3483,6 +3946,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.2" @@ -3574,6 +4078,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.22" @@ -3584,12 +4098,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec 1.15.1", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -3650,6 +4167,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.8.1" @@ -4375,6 +4898,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -4397,6 +4929,17 @@ dependencies = [ "rustix", ] +[[package]] +name = "yaml-rust2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink 0.8.4", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 377d0bb..b8248e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["makima", "tools/stt-client", "vendor/parakeet-rs"] +members = ["makima", "makima/daemon", "tools/stt-client", "vendor/parakeet-rs"] resolver = "2" diff --git a/makima/Cargo.lock b/makima/Cargo.lock deleted file mode 100644 index f3fea81..0000000 --- a/makima/Cargo.lock +++ /dev/null @@ -1,3022 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "serde", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" - -[[package]] -name = "bytemuck" -version = "1.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - -[[package]] -name = "cc" -version = "1.2.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "serde", - "static_assertions", -] - -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.59.0", -] - -[[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-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn", -] - -[[package]] -name = "dary_heap" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" -dependencies = [ - "serde", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[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 = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "esaxx-rs" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" -dependencies = [ - "cc", -] - -[[package]] -name = "extended" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" - -[[package]] -name = "eyre" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" -dependencies = [ - "indenter", - "once_cell", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "filetime" -version = "0.2.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" -dependencies = [ - "cfg-if", - "libc", - "libredox", - "windows-sys 0.60.2", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" - -[[package]] -name = "flate2" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -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", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "h2" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hf-hub" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" -dependencies = [ - "dirs", - "futures", - "http", - "indicatif", - "libc", - "log", - "native-tls", - "num_cpus", - "rand 0.9.2", - "reqwest", - "serde", - "serde_json", - "thiserror 2.0.17", - "tokio", - "ureq 2.12.1", - "windows-sys 0.60.2", -] - -[[package]] -name = "hound" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec 1.15.1", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec 1.15.1", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec 1.15.1", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indenter" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" - -[[package]] -name = "indexmap" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "indicatif" -version = "0.17.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" -dependencies = [ - "console", - "number_prefix", - "portable-atomic", - "unicode-width", - "web-time", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "itertools" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "js-sys" -version = "0.3.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.178" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" - -[[package]] -name = "libredox" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" -dependencies = [ - "bitflags 2.10.0", - "libc", - "redox_syscall", -] - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "macro_rules_attribute" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" -dependencies = [ - "macro_rules_attribute-proc_macro", - "paste", -] - -[[package]] -name = "macro_rules_attribute-proc_macro" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" - -[[package]] -name = "makima" -version = "0.1.0" -dependencies = [ - "hf-hub", - "ndarray", - "ort", - "parakeet-rs", - "symphonia", - "tokenizers 0.21.4", -] - -[[package]] -name = "matrixmultiply" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" -dependencies = [ - "autocfg", - "rawpointer", -] - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "monostate" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" -dependencies = [ - "monostate-impl", - "serde", - "serde_core", -] - -[[package]] -name = "monostate-impl" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[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 = "ndarray" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" -dependencies = [ - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "portable-atomic", - "portable-atomic-util", - "rawpointer", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "onig" -version = "6.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" -dependencies = [ - "bitflags 2.10.0", - "libc", - "once_cell", - "onig_sys", -] - -[[package]] -name = "onig_sys" -version = "69.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "openssl" -version = "0.10.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "foreign-types", - "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", -] - -[[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.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ort" -version = "2.0.0-rc.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e49bd669d32d7bc2a15ec540a527e7764aec722a45467814005725bcd721" -dependencies = [ - "ndarray", - "ort-sys", - "smallvec 2.0.0-alpha.10", - "tracing", -] - -[[package]] -name = "ort-sys" -version = "2.0.0-rc.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2aba9f5c7c479925205799216e7e5d07cc1d4fa76ea8058c60a9a30f6a4e890" -dependencies = [ - "flate2", - "pkg-config", - "sha2", - "tar", - "ureq 3.1.4", -] - -[[package]] -name = "parakeet-rs" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ac0c8598d3cdd54140cff2440b142d6522de5fdcd574beb5e3ab9b0f191875" -dependencies = [ - "eyre", - "hound", - "ndarray", - "ort", - "rustfft", - "serde", - "serde_json", - "tokenizers 0.20.4", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "portable-atomic" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" - -[[package]] -name = "portable-atomic-util" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "primal-check" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" -dependencies = [ - "num-integer", -] - -[[package]] -name = "proc-macro2" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.3", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.3", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.16", -] - -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rawpointer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" - -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-cond" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "059f538b55efd2309c9794130bc149c6a553db90e9d99c2030785c82f0bd7df9" -dependencies = [ - "either", - "itertools 0.11.0", - "rayon", -] - -[[package]] -name = "rayon-cond" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" -dependencies = [ - "either", - "itertools 0.14.0", - "rayon", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.16", - "libredox", - "thiserror 2.0.17", -] - -[[package]] -name = "regex" -version = "1.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - -[[package]] -name = "reqwest" -version = "0.12.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" -dependencies = [ - "base64 0.22.1", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-tls", - "hyper-util", - "js-sys", - "log", - "mime", - "native-tls", - "percent-encoding", - "pin-project-lite", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-native-tls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.16", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustfft" -version = "6.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" -dependencies = [ - "num-complex", - "num-integer", - "num-traits", - "primal-check", - "strength_reduce", - "transpose", -] - -[[package]] -name = "rustix" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" -dependencies = [ - "bitflags 2.10.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "schannel" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.10.0", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.145" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "smallvec" -version = "2.0.0-alpha.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d44cfb396c3caf6fbfd0ab422af02631b69ddd96d2eff0b0f0724f9024051b" - -[[package]] -name = "socket2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "socks" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" -dependencies = [ - "byteorder", - "libc", - "winapi", -] - -[[package]] -name = "spm_precompiled" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" -dependencies = [ - "base64 0.13.1", - "nom", - "serde", - "unicode-segmentation", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "strength_reduce" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "symphonia" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" -dependencies = [ - "lazy_static", - "symphonia-bundle-flac", - "symphonia-bundle-mp3", - "symphonia-codec-aac", - "symphonia-codec-adpcm", - "symphonia-codec-pcm", - "symphonia-codec-vorbis", - "symphonia-core", - "symphonia-format-mkv", - "symphonia-format-ogg", - "symphonia-format-riff", - "symphonia-metadata", -] - -[[package]] -name = "symphonia-bundle-flac" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" -dependencies = [ - "log", - "symphonia-core", - "symphonia-metadata", - "symphonia-utils-xiph", -] - -[[package]] -name = "symphonia-bundle-mp3" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" -dependencies = [ - "lazy_static", - "log", - "symphonia-core", - "symphonia-metadata", -] - -[[package]] -name = "symphonia-codec-aac" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" -dependencies = [ - "lazy_static", - "log", - "symphonia-core", -] - -[[package]] -name = "symphonia-codec-adpcm" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" -dependencies = [ - "log", - "symphonia-core", -] - -[[package]] -name = "symphonia-codec-pcm" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" -dependencies = [ - "log", - "symphonia-core", -] - -[[package]] -name = "symphonia-codec-vorbis" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" -dependencies = [ - "log", - "symphonia-core", - "symphonia-utils-xiph", -] - -[[package]] -name = "symphonia-core" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" -dependencies = [ - "arrayvec", - "bitflags 1.3.2", - "bytemuck", - "lazy_static", - "log", -] - -[[package]] -name = "symphonia-format-mkv" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" -dependencies = [ - "lazy_static", - "log", - "symphonia-core", - "symphonia-metadata", - "symphonia-utils-xiph", -] - -[[package]] -name = "symphonia-format-ogg" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" -dependencies = [ - "log", - "symphonia-core", - "symphonia-metadata", - "symphonia-utils-xiph", -] - -[[package]] -name = "symphonia-format-riff" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" -dependencies = [ - "extended", - "log", - "symphonia-core", - "symphonia-metadata", -] - -[[package]] -name = "symphonia-metadata" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" -dependencies = [ - "encoding_rs", - "lazy_static", - "log", - "symphonia-core", -] - -[[package]] -name = "symphonia-utils-xiph" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" -dependencies = [ - "symphonia-core", - "symphonia-metadata", -] - -[[package]] -name = "syn" -version = "2.0.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags 2.10.0", - "core-foundation", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tar" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "tempfile" -version = "3.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" -dependencies = [ - "fastrand", - "getrandom 0.3.4", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" -dependencies = [ - "thiserror-impl 2.0.17", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokenizers" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b08cc37428a476fc9e20ac850132a513a2e1ce32b6a31addf2b74fa7033b905" -dependencies = [ - "aho-corasick", - "derive_builder", - "esaxx-rs", - "getrandom 0.2.16", - "indicatif", - "itertools 0.12.1", - "lazy_static", - "log", - "macro_rules_attribute", - "monostate", - "onig", - "paste", - "rand 0.8.5", - "rayon", - "rayon-cond 0.3.0", - "regex", - "regex-syntax", - "serde", - "serde_json", - "spm_precompiled", - "thiserror 1.0.69", - "unicode-normalization-alignments", - "unicode-segmentation", - "unicode_categories", -] - -[[package]] -name = "tokenizers" -version = "0.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" -dependencies = [ - "ahash", - "aho-corasick", - "compact_str", - "dary_heap", - "derive_builder", - "esaxx-rs", - "getrandom 0.3.4", - "indicatif", - "itertools 0.14.0", - "log", - "macro_rules_attribute", - "monostate", - "onig", - "paste", - "rand 0.9.2", - "rayon", - "rayon-cond 0.4.0", - "regex", - "regex-syntax", - "serde", - "serde_json", - "spm_precompiled", - "thiserror 2.0.17", - "unicode-normalization-alignments", - "unicode-segmentation", - "unicode_categories", -] - -[[package]] -name = "tokio" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[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-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags 2.10.0", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" -dependencies = [ - "once_cell", -] - -[[package]] -name = "transpose" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" -dependencies = [ - "num-integer", - "strength_reduce", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "unicode-normalization-alignments" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" -dependencies = [ - "smallvec 1.15.1", -] - -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode_categories" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "native-tls", - "once_cell", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "socks", - "url", - "webpki-roots 0.26.11", -] - -[[package]] -name = "ureq" -version = "3.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" -dependencies = [ - "base64 0.22.1", - "der", - "log", - "native-tls", - "percent-encoding", - "rustls-pki-types", - "socks", - "ureq-proto", - "utf-8", - "webpki-root-certs", -] - -[[package]] -name = "ureq-proto" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" -dependencies = [ - "base64 0.22.1", - "http", - "httparse", - "log", -] - -[[package]] -name = "url" -version = "2.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" -dependencies = [ - "cfg-if", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee3e3b5f5e80bc89f30ce8d0343bf4e5f12341c51f3e26cbeecbc7c85443e85b" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.4", -] - -[[package]] -name = "webpki-roots" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/makima/Cargo.toml b/makima/Cargo.toml index 4bf629f..5d8c44e 100644 --- a/makima/Cargo.toml +++ b/makima/Cargo.toml @@ -37,6 +37,9 @@ utoipa-swagger-ui = { version = "9", features = ["axum"] } thiserror = "2.0" anyhow = "1.0" +# Concurrent data structures +dashmap = "6.0" + # Database sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] } chrono = { version = "0.4", features = ["serde"] } @@ -47,6 +50,15 @@ reqwest = { version = "0.12", features = ["json"] } # Lazy statics once_cell = "1.19" +# Cryptographic hashing for API keys +sha2 = "0.10" +rand = { version = "0.8", features = ["std", "std_rng"] } +base64 = "0.22" +hex = "0.4" + +# JWT authentication +jsonwebtoken = "9" + # JQ for JSON transformation jaq-interpret = "1.5" jaq-parse = "1.0" diff --git a/makima/daemon/Cargo.toml b/makima/daemon/Cargo.toml new file mode 100644 index 0000000..02ecbb3 --- /dev/null +++ b/makima/daemon/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "makima-daemon" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "makima-daemon" +path = "src/main.rs" + +[dependencies] +# Async runtime +tokio = { version = "1.0", features = ["full", "signal", "process"] } +futures = "0.3" +async-trait = "0.1" + +# WebSocket client +tokio-tungstenite = { version = "0.24", features = ["native-tls"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Configuration +config = "0.14" +clap = { version = "4.4", features = ["derive", "env"] } + +# Database (local state) +rusqlite = { version = "0.32", features = ["bundled"] } + +# Error handling +thiserror = "2.0" +anyhow = "1.0" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +# Utilities +uuid = { version = "1.0", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +dashmap = "6.0" +backoff = { version = "0.4", features = ["tokio"] } +hostname = "0.4" +sha2 = "0.10" +hex = "0.4" +shell-escape = "0.1" +dirs = "5.0" +rand = "0.9" diff --git a/makima/daemon/README.md b/makima/daemon/README.md new file mode 100644 index 0000000..7c577c5 --- /dev/null +++ b/makima/daemon/README.md @@ -0,0 +1,353 @@ +# Makima Daemon + +The Makima daemon connects to the Makima server and executes tasks using Claude Code in isolated git worktrees. + +## Installation + +```bash +cd makima/daemon +cargo build --release +``` + +The binary will be at `target/release/makima-daemon`. + +## Quick Start + +```bash +# Set required environment variables +export MAKIMA_API_KEY="your-api-key" +export MAKIMA_DAEMON_SERVER_URL="ws://localhost:8080" + +# Run the daemon +makima-daemon +``` + +## Configuration + +Configuration is loaded from multiple sources in order of precedence (highest first): + +1. CLI arguments +2. Environment variables +3. `./makima-daemon.toml` (current directory) +4. `~/.config/makima-daemon/config.toml` (user config) +5. `/etc/makima-daemon/config.toml` (system config, Linux only) + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `MAKIMA_API_KEY` | API key for authentication (preferred) | +| `MAKIMA_DAEMON_SERVER_URL` | WebSocket URL of the Makima server | +| `MAKIMA_DAEMON_SERVER_APIKEY` | Alternative to MAKIMA_API_KEY | +| `MAKIMA_DAEMON_PROCESS_MAXCONCURRENTTASKS` | Max concurrent tasks | + +### CLI Arguments + +``` +makima-daemon [OPTIONS] + +Options: + -c, --config Path to config file + -s, --server-url WebSocket URL of makima server + -k, --api-key API key for authentication + -m, --max-tasks Maximum concurrent tasks + -l, --log-level Log level (trace, debug, info, warn, error) + --repos-dir Directory for cloned repositories + --worktrees-dir Directory for worktrees + -h, --help Print help + -V, --version Print version +``` + +--- + +## Configuration File Reference + +Below is a complete configuration file with all options and their defaults. + +```toml +# ============================================================================= +# Server Connection +# ============================================================================= +[server] +# WebSocket URL of the Makima server (required) +url = "ws://localhost:8080" + +# API key for authentication (required) +# Can also be set via MAKIMA_API_KEY environment variable +api_key = "your-api-key" + +# Heartbeat interval in seconds (default: 30) +heartbeat_interval_secs = 30 + +# Reconnect interval after connection loss in seconds (default: 5) +reconnect_interval_secs = 5 + +# Maximum reconnect attempts before giving up (default: 0 = infinite) +max_reconnect_attempts = 0 + +# ============================================================================= +# Worktree Settings +# ============================================================================= +[worktree] +# Base directory for task worktrees (default: ~/.makima/worktrees) +base_dir = "/home/user/.makima/worktrees" + +# Directory for cached repository clones (default: ~/.makima/repos) +repos_dir = "/home/user/.makima/repos" + +# Branch prefix for task branches (default: "makima/task-") +branch_prefix = "makima/task-" + +# Clean up old worktrees on daemon start (default: false) +cleanup_on_start = false + +# Default target repository for pushing completed branches +# Used when task.target_repo_path is not set +default_target_repo = "/home/user/projects/my-repo" + +# ============================================================================= +# Process Settings (Claude Code) +# ============================================================================= +[process] +# Path or command for Claude Code CLI (default: "claude") +claude_command = "claude" + +# Additional arguments to pass to Claude Code (after default arguments) +# Default arguments are: --output-format=stream-json --input-format=stream-json +# --verbose --dangerously-skip-permissions +claude_args = ["--model", "opus"] + +# Arguments to pass before default arguments (for overriding defaults) +claude_pre_args = [] + +# Enable Claude's permission system (default: false) +# When true, removes --dangerously-skip-permissions flag +enable_permissions = false + +# Disable verbose output (default: false) +# When true, removes --verbose flag +disable_verbose = false + +# Maximum concurrent tasks (default: 4) +max_concurrent_tasks = 4 + +# Default timeout for tasks in seconds (default: 0 = no timeout) +default_timeout_secs = 0 + +# Additional environment variables to pass to Claude Code +[process.env_vars] +ANTHROPIC_API_KEY = "sk-ant-..." +CUSTOM_VAR = "value" + +# ============================================================================= +# Repository Auto-Clone +# ============================================================================= +[repos] +# Directory to clone repositories into (default: ~/.makima/home) +home_dir = "/home/user/.makima/home" + +# List of repositories to auto-clone on startup +# Repositories that already exist are skipped + +# Simple format - just URLs +auto_clone = [ + "https://github.com/user/repo1.git", + "https://github.com/user/repo2.git", +] + +# Shorthand format supported: +# github:user/repo -> https://github.com/user/repo.git +# gitlab:user/repo -> https://gitlab.com/user/repo.git +auto_clone = [ + "github:anthropics/claude-code", + "gitlab:company/project", +] + +# Detailed format with options (use [[repos.auto_clone]] for each entry) +[[repos.auto_clone]] +url = "github:user/repo" +name = "custom-directory-name" # Optional: override directory name +branch = "develop" # Optional: checkout specific branch +shallow = true # Optional: shallow clone (--depth 1) + +[[repos.auto_clone]] +url = "https://github.com/org/large-repo.git" +shallow = true # Faster clone for large repos + +# ============================================================================= +# Local Database +# ============================================================================= +[local_db] +# Path to local SQLite database (default: ~/.makima/daemon.db) +path = "/home/user/.makima/daemon.db" + +# ============================================================================= +# Logging +# ============================================================================= +[logging] +# Log level: trace, debug, info, warn, error (default: "info") +level = "info" + +# Log format: "pretty" or "json" (default: "pretty") +format = "pretty" +``` + +--- + +## Examples + +### Minimal Configuration + +```toml +[server] +url = "ws://localhost:8080" +api_key = "your-api-key" +``` + +### Production Configuration + +```toml +[server] +url = "wss://api.makima.example.com/daemon" +api_key = "prod-api-key" +heartbeat_interval_secs = 30 +reconnect_interval_secs = 10 +max_reconnect_attempts = 0 + +[worktree] +base_dir = "/var/lib/makima/worktrees" +repos_dir = "/var/lib/makima/repos" +cleanup_on_start = true + +[process] +max_concurrent_tasks = 8 +default_timeout_secs = 3600 # 1 hour timeout + +[logging] +level = "info" +format = "json" +``` + +### Development with Custom Claude + +```toml +[server] +url = "ws://localhost:8080" +api_key = "dev-key" + +[process] +# Use a specific claude binary +claude_command = "/usr/local/bin/claude-dev" + +# Add custom arguments +claude_args = ["--model", "sonnet", "--max-turns", "50"] + +# Enable permission prompts for testing +enable_permissions = true + +[process.env_vars] +ANTHROPIC_API_KEY = "sk-ant-dev-..." +DEBUG = "1" + +[logging] +level = "debug" +``` + +### Auto-Clone Team Repositories + +```toml +[server] +url = "ws://localhost:8080" +api_key = "team-key" + +[repos] +home_dir = "/home/dev/.makima/projects" + +# Clone all team repos on startup +[[repos.auto_clone]] +url = "github:myteam/frontend" +branch = "main" + +[[repos.auto_clone]] +url = "github:myteam/backend" +branch = "main" + +[[repos.auto_clone]] +url = "github:myteam/shared-libs" +shallow = true # Only need latest commit +``` + +--- + +## Directory Structure + +After running, the daemon creates the following directories: + +``` +~/.makima/ +├── daemon.db # Local state database +├── worktrees/ # Task worktrees (temporary) +│ └── task-abc123/ # Individual task worktree +├── repos/ # Cached repository clones +│ └── repo-name/ # Bare clone for worktree creation +└── home/ # Auto-cloned repositories + └── my-repo/ # Full repository clone +``` + +--- + +## Troubleshooting + +### Connection Issues + +```bash +# Check server connectivity +curl -I http://localhost:8080/health + +# Run with debug logging +makima-daemon --log-level debug +``` + +### Claude Code Not Found + +```bash +# Verify claude is installed and in PATH +which claude +claude --version + +# Or specify full path in config +[process] +claude_command = "/full/path/to/claude" +``` + +### Permission Errors + +If Claude Code requires permissions, either: + +1. Use `--dangerously-skip-permissions` (default behavior) +2. Set `enable_permissions = true` and handle permission prompts +3. Pre-configure Claude Code permissions in `~/.claude/` + +### Task Timeouts + +Set an appropriate timeout for long-running tasks: + +```toml +[process] +default_timeout_secs = 7200 # 2 hours +``` + +--- + +## Environment Variable Reference + +All configuration options can be set via environment variables using the pattern: +`MAKIMA_DAEMON_
_` + +Examples: +- `MAKIMA_DAEMON_SERVER_URL` -> `server.url` +- `MAKIMA_DAEMON_PROCESS_MAXCONCURRENTTASKS` -> `process.max_concurrent_tasks` +- `MAKIMA_DAEMON_LOGGING_LEVEL` -> `logging.level` + +Special case: +- `MAKIMA_API_KEY` -> `server.api_key` (preferred method) diff --git a/makima/daemon/src/cli.rs b/makima/daemon/src/cli.rs new file mode 100644 index 0000000..ca84017 --- /dev/null +++ b/makima/daemon/src/cli.rs @@ -0,0 +1,45 @@ +//! Command-line argument parsing for makima-daemon. + +use clap::Parser; +use std::path::PathBuf; + +/// Makima daemon for managing Claude Code instances in isolated worktrees. +#[derive(Parser, Debug)] +#[command(name = "makima-daemon")] +#[command(version, about, long_about = None)] +pub struct Cli { + /// Path to custom config file + #[arg(short, long)] + pub config: Option, + + /// Directory where repositories are cloned + #[arg(long, env = "MAKIMA_DAEMON_REPOS_DIR")] + pub repos_dir: Option, + + /// Directory where worktrees are created + #[arg(long, env = "MAKIMA_DAEMON_WORKTREES_DIR")] + pub worktrees_dir: Option, + + /// WebSocket server URL to connect to + #[arg(long, env = "MAKIMA_DAEMON_SERVER_URL")] + pub server_url: Option, + + /// API key for server authentication + #[arg(long, env = "MAKIMA_DAEMON_SERVER_APIKEY")] + pub api_key: Option, + + /// Maximum number of concurrent tasks + #[arg(long)] + pub max_tasks: Option, + + /// Log level (trace, debug, info, warn, error) + #[arg(short, long, default_value = "info")] + pub log_level: String, +} + +impl Cli { + /// Parse command-line arguments + pub fn parse_args() -> Self { + Self::parse() + } +} diff --git a/makima/daemon/src/config.rs b/makima/daemon/src/config.rs new file mode 100644 index 0000000..28c7fea --- /dev/null +++ b/makima/daemon/src/config.rs @@ -0,0 +1,536 @@ +//! Configuration management for the makima daemon. + +use config::{Config, Environment, File}; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Root daemon configuration. +#[derive(Debug, Clone, Deserialize)] +pub struct DaemonConfig { + /// Server connection settings. + pub server: ServerConfig, + + /// Worktree settings. + #[serde(default)] + pub worktree: WorktreeConfig, + + /// Process settings. + #[serde(default)] + pub process: ProcessConfig, + + /// Local database settings. + #[serde(default)] + pub local_db: LocalDbConfig, + + /// Logging settings. + #[serde(default)] + pub logging: LoggingConfig, + + /// Repositories to auto-clone on startup. + #[serde(default)] + pub repos: ReposConfig, +} + +/// Server connection configuration. +#[derive(Debug, Clone, Deserialize)] +pub struct ServerConfig { + /// WebSocket URL of makima server (e.g., ws://localhost:8080 or wss://makima.example.com). + #[serde(default)] + pub url: String, + + /// API key for authentication. + #[serde(default, alias = "apikey")] + pub api_key: String, + + /// Heartbeat interval in seconds. + #[serde(default = "default_heartbeat_interval", alias = "heartbeatintervalsecs")] + pub heartbeat_interval_secs: u64, + + /// Reconnect interval in seconds after connection loss. + #[serde(default = "default_reconnect_interval", alias = "reconnectintervalsecs")] + pub reconnect_interval_secs: u64, + + /// Maximum reconnect attempts before giving up (0 = infinite). + #[serde(default, alias = "maxreconnectattempts")] + pub max_reconnect_attempts: u32, +} + +fn default_heartbeat_interval() -> u64 { + 30 +} + +fn default_reconnect_interval() -> u64 { + 5 +} + +/// Worktree configuration for task isolation. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct WorktreeConfig { + /// Base directory for worktrees (~/.makima/worktrees). + #[serde(default = "default_worktree_base_dir", alias = "basedir")] + pub base_dir: PathBuf, + + /// Base directory for cloned repositories (~/.makima/repos). + #[serde(default = "default_repos_base_dir", alias = "reposdir")] + pub repos_dir: PathBuf, + + /// Branch prefix for task branches. + #[serde(default = "default_branch_prefix", alias = "branchprefix")] + pub branch_prefix: String, + + /// Clean up worktrees on daemon start. + #[serde(default, alias = "cleanuponstart")] + pub cleanup_on_start: bool, + + /// Default target repository path for pushing completed branches. + /// Used when task.target_repo_path is not set. + #[serde(default, alias = "defaulttargetrepo")] + pub default_target_repo: Option, +} + +fn default_worktree_base_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".makima") + .join("worktrees") +} + +fn default_repos_base_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".makima") + .join("repos") +} + +fn default_branch_prefix() -> String { + "makima/task-".to_string() +} + +impl Default for WorktreeConfig { + fn default() -> Self { + Self { + base_dir: default_worktree_base_dir(), + repos_dir: default_repos_base_dir(), + branch_prefix: default_branch_prefix(), + cleanup_on_start: false, + default_target_repo: None, + } + } +} + +/// Process configuration for Claude Code subprocess execution. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct ProcessConfig { + /// Path or command for Claude Code CLI. + #[serde(default = "default_claude_command", alias = "claudecommand")] + pub claude_command: String, + + /// Additional arguments to pass to Claude Code. + /// These are added after the default arguments. + #[serde(default, alias = "claudeargs")] + pub claude_args: Vec, + + /// Arguments to pass before the default arguments. + /// Useful for overriding defaults. + #[serde(default, alias = "claudepreargs")] + pub claude_pre_args: Vec, + + /// Skip the --dangerously-skip-permissions flag (default: false). + /// Set to true if you want to use Claude's permission system. + #[serde(default, alias = "enablepermissions")] + pub enable_permissions: bool, + + /// Skip the --verbose flag (default: false). + #[serde(default, alias = "disableverbose")] + pub disable_verbose: bool, + + /// Maximum concurrent tasks. + #[serde(default = "default_max_tasks", alias = "maxconcurrenttasks")] + pub max_concurrent_tasks: u32, + + /// Default timeout for tasks in seconds (0 = no timeout). + #[serde(default, alias = "defaulttimeoutsecs")] + pub default_timeout_secs: u64, + + /// Additional environment variables to pass to Claude Code. + #[serde(default, alias = "envvars")] + pub env_vars: HashMap, +} + +fn default_claude_command() -> String { + "claude".to_string() +} + +fn default_max_tasks() -> u32 { + 4 +} + +impl Default for ProcessConfig { + fn default() -> Self { + Self { + claude_command: default_claude_command(), + claude_args: Vec::new(), + claude_pre_args: Vec::new(), + enable_permissions: false, + disable_verbose: false, + max_concurrent_tasks: default_max_tasks(), + default_timeout_secs: 0, + env_vars: HashMap::new(), + } + } +} + +/// Local database configuration. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct LocalDbConfig { + /// Path to local SQLite database. + #[serde(default = "default_db_path")] + pub path: PathBuf, +} + +impl Default for LocalDbConfig { + fn default() -> Self { + Self { + path: default_db_path(), + } + } +} + +fn default_db_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".makima") + .join("daemon.db") +} + +/// Logging configuration. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct LoggingConfig { + /// Log level: "trace", "debug", "info", "warn", "error". + #[serde(default = "default_log_level")] + pub level: String, + + /// Log format: "pretty" or "json". + #[serde(default = "default_log_format")] + pub format: String, +} + +fn default_log_level() -> String { + "info".to_string() +} + +fn default_log_format() -> String { + "pretty".to_string() +} + +/// Repository auto-clone configuration. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct ReposConfig { + /// Directory to clone repositories into (default: ~/.makima/home). + #[serde(default = "default_home_dir")] + pub home_dir: PathBuf, + + /// List of repositories to auto-clone on startup. + /// Each entry can be a URL (e.g., "https://github.com/user/repo.git") + /// or a shorthand (e.g., "github:user/repo"). + #[serde(default, alias = "autoclone")] + pub auto_clone: Vec, +} + +/// A repository entry for auto-cloning. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum RepoEntry { + /// Simple URL string. + Url(String), + /// Detailed configuration. + Config { + /// Repository URL. + url: String, + /// Custom directory name (defaults to repo name from URL). + #[serde(default)] + name: Option, + /// Branch to checkout after cloning (defaults to default branch). + #[serde(default)] + branch: Option, + /// Whether to do a shallow clone (default: false). + #[serde(default)] + shallow: bool, + }, +} + +impl RepoEntry { + /// Get the URL for this repo entry. + pub fn url(&self) -> &str { + match self { + RepoEntry::Url(url) => url, + RepoEntry::Config { url, .. } => url, + } + } + + /// Get the custom name, if any. + pub fn name(&self) -> Option<&str> { + match self { + RepoEntry::Url(_) => None, + RepoEntry::Config { name, .. } => name.as_deref(), + } + } + + /// Get the branch to checkout, if any. + pub fn branch(&self) -> Option<&str> { + match self { + RepoEntry::Url(_) => None, + RepoEntry::Config { branch, .. } => branch.as_deref(), + } + } + + /// Whether to do a shallow clone. + pub fn shallow(&self) -> bool { + match self { + RepoEntry::Url(_) => false, + RepoEntry::Config { shallow, .. } => *shallow, + } + } + + /// Get the directory name to use (either custom name or derived from URL). + pub fn dir_name(&self) -> Option { + if let Some(name) = self.name() { + return Some(name.to_string()); + } + + // Derive from URL + let url = self.url(); + + // Handle shorthand formats + let url = if url.starts_with("github:") { + url.strip_prefix("github:").unwrap_or(url) + } else if url.starts_with("gitlab:") { + url.strip_prefix("gitlab:").unwrap_or(url) + } else { + url + }; + + // Extract repo name from URL + url.trim_end_matches('/') + .trim_end_matches(".git") + .rsplit('/') + .next() + .map(|s| s.to_string()) + } + + /// Expand the URL (e.g., convert shorthand to full URL). + pub fn expanded_url(&self) -> String { + let url = self.url(); + + if url.starts_with("github:") { + format!("https://github.com/{}.git", url.strip_prefix("github:").unwrap_or("")) + } else if url.starts_with("gitlab:") { + format!("https://gitlab.com/{}.git", url.strip_prefix("gitlab:").unwrap_or("")) + } else { + url.to_string() + } + } +} + +fn default_home_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".makima") + .join("home") +} + +impl DaemonConfig { + /// Load configuration from files and environment variables. + /// + /// Configuration sources (in order of precedence): + /// 1. Environment variables (MAKIMA_API_KEY, MAKIMA_DAEMON_SERVER_URL, etc.) + /// 2. ./makima-daemon.toml (current directory) + /// 3. ~/.config/makima-daemon/config.toml + /// 4. /etc/makima-daemon/config.toml (Linux only) + /// + /// Environment variable examples: + /// - MAKIMA_API_KEY=your-api-key (preferred) + /// - MAKIMA_DAEMON_SERVER_URL=ws://localhost:8080 + /// - MAKIMA_DAEMON_PROCESS_MAXCONCURRENTTASKS=4 + pub fn load() -> Result { + Self::load_from_path(None) + } + + /// Load configuration from a specific path plus standard sources. + fn load_from_path(config_path: Option<&std::path::Path>) -> Result { + let mut builder = Config::builder(); + + // System-wide config (Linux only) + #[cfg(target_os = "linux")] + { + builder = builder.add_source( + File::with_name("/etc/makima-daemon/config").required(false), + ); + } + + // User config + if let Some(config_dir) = dirs::config_dir() { + let user_config = config_dir.join("makima-daemon").join("config"); + builder = builder.add_source( + File::with_name(user_config.to_str().unwrap_or("")).required(false), + ); + } + + // Local config + builder = builder.add_source(File::with_name("makima-daemon").required(false)); + + // Custom config file (if provided) + if let Some(path) = config_path { + builder = builder.add_source( + File::with_name(path.to_str().unwrap_or("")).required(true), + ); + } + + // Environment variables with underscore separator for nesting + // e.g., MAKIMA_DAEMON_SERVER_URL -> server.url + // MAKIMA_DAEMON_SERVER_APIKEY -> server.api_key + builder = builder.add_source( + Environment::with_prefix("MAKIMA_DAEMON") + .separator("_") + .try_parsing(true), + ); + + let config = builder.build()?; + let mut config: DaemonConfig = config.try_deserialize()?; + + // Check for MAKIMA_API_KEY environment variable (preferred over MAKIMA_DAEMON_SERVER_APIKEY) + if let Ok(api_key) = std::env::var("MAKIMA_API_KEY") { + config.server.api_key = api_key; + } + + // Validate required fields (don't validate here - let load_with_cli do final validation) + Ok(config) + } + + /// Validate that required configuration fields are set. + pub fn validate(&self) -> Result<(), config::ConfigError> { + if self.server.url.is_empty() { + return Err(config::ConfigError::Message( + "server.url is required. Set via config file, MAKIMA_DAEMON_SERVER_URL, or --server-url".to_string() + )); + } + if self.server.api_key.is_empty() { + return Err(config::ConfigError::Message( + "API key is required. Set via MAKIMA_API_KEY, config file, or --api-key".to_string() + )); + } + Ok(()) + } + + /// Load configuration with CLI argument overrides. + /// + /// Configuration sources (in order of precedence, highest first): + /// 1. CLI arguments + /// 2. Environment variables + /// 3. Custom config file (if --config specified) + /// 4. ./makima-daemon.toml (current directory) + /// 5. ~/.config/makima-daemon/config.toml + /// 6. /etc/makima-daemon/config.toml (Linux only) + /// 7. Default values + pub fn load_with_cli(cli: &crate::cli::Cli) -> Result { + // Load base config (with optional custom config file) + let mut config = Self::load_from_path(cli.config.as_deref())?; + + // Apply CLI overrides (highest priority) + if let Some(ref repos_dir) = cli.repos_dir { + config.worktree.repos_dir = repos_dir.clone(); + } + if let Some(ref worktrees_dir) = cli.worktrees_dir { + config.worktree.base_dir = worktrees_dir.clone(); + } + if let Some(ref server_url) = cli.server_url { + config.server.url = server_url.clone(); + } + if let Some(ref api_key) = cli.api_key { + config.server.api_key = api_key.clone(); + } + if let Some(max_tasks) = cli.max_tasks { + config.process.max_concurrent_tasks = max_tasks; + } + // Log level is always set (has default) + config.logging.level = cli.log_level.clone(); + + // Validate required fields after all sources are merged + config.validate()?; + + Ok(config) + } + + /// Create a minimal config for testing. + #[cfg(test)] + pub fn test_config() -> Self { + Self { + server: ServerConfig { + url: "ws://localhost:8080".to_string(), + api_key: "test-key".to_string(), + heartbeat_interval_secs: 30, + reconnect_interval_secs: 5, + max_reconnect_attempts: 0, + }, + worktree: WorktreeConfig { + base_dir: PathBuf::from("/tmp/makima-daemon-test/worktrees"), + repos_dir: PathBuf::from("/tmp/makima-daemon-test/repos"), + branch_prefix: "makima/task-".to_string(), + cleanup_on_start: true, + default_target_repo: None, + }, + process: ProcessConfig { + claude_command: "claude".to_string(), + claude_args: Vec::new(), + claude_pre_args: Vec::new(), + enable_permissions: false, + disable_verbose: false, + max_concurrent_tasks: 2, + default_timeout_secs: 0, + env_vars: HashMap::new(), + }, + local_db: LocalDbConfig { + path: PathBuf::from("/tmp/makima-daemon-test/state.db"), + }, + logging: LoggingConfig::default(), + repos: ReposConfig::default(), + } + } +} + +/// Helper module for dirs crate (minimal subset). +mod dirs { + use std::path::PathBuf; + + pub fn home_dir() -> Option { + std::env::var("HOME").ok().map(PathBuf::from) + } + + pub fn config_dir() -> Option { + #[cfg(target_os = "macos")] + { + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join("Library").join("Application Support")) + } + #[cfg(target_os = "linux")] + { + std::env::var("XDG_CONFIG_HOME") + .ok() + .map(PathBuf::from) + .or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".config"))) + } + #[cfg(target_os = "windows")] + { + std::env::var("APPDATA").ok().map(PathBuf::from) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + None + } + } +} diff --git a/makima/daemon/src/db/local.rs b/makima/daemon/src/db/local.rs new file mode 100644 index 0000000..5adbf98 --- /dev/null +++ b/makima/daemon/src/db/local.rs @@ -0,0 +1,391 @@ +//! Local SQLite database for crash recovery and state persistence. + +use std::path::Path; + +use chrono::{DateTime, Utc}; +use rusqlite::{params, Connection, Result as SqliteResult}; +use uuid::Uuid; + +use crate::task::TaskState; + +/// Local task record for persistence. +#[derive(Debug, Clone)] +pub struct LocalTask { + pub id: Uuid, + pub server_task_id: Uuid, + pub state: TaskState, + pub container_id: Option, + pub overlay_path: Option, + pub repo_url: Option, + pub base_branch: Option, + pub plan: String, + pub created_at: DateTime, + pub started_at: Option>, + pub completed_at: Option>, + pub error_message: Option, +} + +/// Buffered output for reliable delivery. +#[derive(Debug, Clone)] +pub struct BufferedOutput { + pub id: i64, + pub task_id: Uuid, + pub output: String, + pub is_partial: bool, + pub timestamp: DateTime, +} + +/// Local database for daemon state persistence. +pub struct LocalDb { + conn: Connection, +} + +impl LocalDb { + /// Open or create the local database. + pub fn open(path: &Path) -> SqliteResult { + // Create parent directory if needed + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok(); + } + + let conn = Connection::open(path)?; + + // Initialize schema + conn.execute_batch(Self::schema())?; + + Ok(Self { conn }) + } + + /// Open an in-memory database (for testing). + #[cfg(test)] + pub fn open_memory() -> SqliteResult { + let conn = Connection::open_in_memory()?; + conn.execute_batch(Self::schema())?; + Ok(Self { conn }) + } + + /// Database schema. + fn schema() -> &'static str { + r#" + -- Local task state for crash recovery + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + server_task_id TEXT NOT NULL, + state TEXT NOT NULL, + container_id TEXT, + overlay_path TEXT, + repo_url TEXT, + base_branch TEXT, + plan TEXT NOT NULL, + created_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + error_message TEXT + ); + + -- Buffered output for reliable delivery + CREATE TABLE IF NOT EXISTS output_buffer ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + output TEXT NOT NULL, + is_partial INTEGER NOT NULL, + timestamp TEXT NOT NULL, + sent INTEGER NOT NULL DEFAULT 0 + ); + + -- Daemon state key-value store + CREATE TABLE IF NOT EXISTS daemon_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + -- Indexes + CREATE INDEX IF NOT EXISTS idx_tasks_state ON tasks(state); + CREATE INDEX IF NOT EXISTS idx_output_buffer_sent ON output_buffer(sent, id); + CREATE INDEX IF NOT EXISTS idx_output_buffer_task ON output_buffer(task_id); + "# + } + + /// Save a task. + pub fn save_task(&self, task: &LocalTask) -> SqliteResult<()> { + self.conn.execute( + r#" + INSERT OR REPLACE INTO tasks + (id, server_task_id, state, container_id, overlay_path, repo_url, base_branch, plan, created_at, started_at, completed_at, error_message) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + "#, + params![ + task.id.to_string(), + task.server_task_id.to_string(), + task.state.as_str(), + task.container_id, + task.overlay_path, + task.repo_url, + task.base_branch, + task.plan, + task.created_at.to_rfc3339(), + task.started_at.map(|t| t.to_rfc3339()), + task.completed_at.map(|t| t.to_rfc3339()), + task.error_message, + ], + )?; + Ok(()) + } + + /// Get a task by ID. + pub fn get_task(&self, id: Uuid) -> SqliteResult> { + let mut stmt = self.conn.prepare( + "SELECT id, server_task_id, state, container_id, overlay_path, repo_url, base_branch, plan, created_at, started_at, completed_at, error_message FROM tasks WHERE id = ?1", + )?; + + let mut rows = stmt.query(params![id.to_string()])?; + + if let Some(row) = rows.next()? { + Ok(Some(Self::task_from_row(row)?)) + } else { + Ok(None) + } + } + + /// Get all running/active tasks (for recovery). + pub fn get_active_tasks(&self) -> SqliteResult> { + let mut stmt = self.conn.prepare( + r#" + SELECT id, server_task_id, state, container_id, overlay_path, repo_url, base_branch, plan, created_at, started_at, completed_at, error_message + FROM tasks + WHERE state IN ('initializing', 'starting', 'running', 'paused', 'blocked') + "#, + )?; + + let rows = stmt.query_map([], |row| Self::task_from_row(row))?; + + rows.collect() + } + + /// Delete a task. + pub fn delete_task(&self, id: Uuid) -> SqliteResult<()> { + self.conn.execute( + "DELETE FROM tasks WHERE id = ?1", + params![id.to_string()], + )?; + Ok(()) + } + + /// Update task state. + pub fn update_task_state(&self, id: Uuid, state: TaskState) -> SqliteResult<()> { + self.conn.execute( + "UPDATE tasks SET state = ?2 WHERE id = ?1", + params![id.to_string(), state.as_str()], + )?; + Ok(()) + } + + /// Buffer output for reliable delivery. + pub fn buffer_output(&self, task_id: Uuid, output: &str, is_partial: bool) -> SqliteResult { + self.conn.execute( + r#" + INSERT INTO output_buffer (task_id, output, is_partial, timestamp, sent) + VALUES (?1, ?2, ?3, datetime('now'), 0) + "#, + params![task_id.to_string(), output, is_partial as i32], + )?; + Ok(self.conn.last_insert_rowid()) + } + + /// Get unsent outputs. + pub fn get_unsent_outputs(&self, limit: i64) -> SqliteResult> { + let mut stmt = self.conn.prepare( + r#" + SELECT id, task_id, output, is_partial, timestamp + FROM output_buffer + WHERE sent = 0 + ORDER BY id + LIMIT ?1 + "#, + )?; + + let rows = stmt.query_map(params![limit], |row| { + let id: i64 = row.get(0)?; + let task_id_str: String = row.get(1)?; + let task_id = Uuid::parse_str(&task_id_str).unwrap_or_default(); + let output: String = row.get(2)?; + let is_partial: i32 = row.get(3)?; + let timestamp_str: String = row.get(4)?; + let timestamp = DateTime::parse_from_rfc3339(×tamp_str) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + + Ok(BufferedOutput { + id, + task_id, + output, + is_partial: is_partial != 0, + timestamp, + }) + })?; + + rows.collect() + } + + /// Mark outputs as sent. + pub fn mark_outputs_sent(&self, ids: &[i64]) -> SqliteResult<()> { + if ids.is_empty() { + return Ok(()); + } + + let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect(); + let sql = format!( + "UPDATE output_buffer SET sent = 1 WHERE id IN ({})", + placeholders.join(",") + ); + + let params: Vec = ids + .iter() + .map(|id| rusqlite::types::Value::Integer(*id)) + .collect(); + + self.conn.execute(&sql, rusqlite::params_from_iter(params))?; + Ok(()) + } + + /// Clean up old sent outputs. + pub fn cleanup_sent_outputs(&self, older_than_hours: i64) -> SqliteResult { + let result = self.conn.execute( + r#" + DELETE FROM output_buffer + WHERE sent = 1 AND timestamp < datetime('now', ?1 || ' hours') + "#, + params![format!("-{}", older_than_hours)], + )?; + Ok(result) + } + + /// Get daemon state value. + pub fn get_state(&self, key: &str) -> SqliteResult> { + let mut stmt = self.conn.prepare( + "SELECT value FROM daemon_state WHERE key = ?1", + )?; + + let mut rows = stmt.query(params![key])?; + + if let Some(row) = rows.next()? { + let value: String = row.get(0)?; + Ok(Some(value)) + } else { + Ok(None) + } + } + + /// Set daemon state value. + pub fn set_state(&self, key: &str, value: &str) -> SqliteResult<()> { + self.conn.execute( + r#" + INSERT OR REPLACE INTO daemon_state (key, value, updated_at) + VALUES (?1, ?2, datetime('now')) + "#, + params![key, value], + )?; + Ok(()) + } + + /// Parse a task from a database row. + fn task_from_row(row: &rusqlite::Row) -> SqliteResult { + let id_str: String = row.get(0)?; + let server_task_id_str: String = row.get(1)?; + let state_str: String = row.get(2)?; + let container_id: Option = row.get(3)?; + let overlay_path: Option = row.get(4)?; + let repo_url: Option = row.get(5)?; + let base_branch: Option = row.get(6)?; + let plan: String = row.get(7)?; + let created_at_str: String = row.get(8)?; + let started_at_str: Option = row.get(9)?; + let completed_at_str: Option = row.get(10)?; + let error_message: Option = row.get(11)?; + + let id = Uuid::parse_str(&id_str).unwrap_or_default(); + let server_task_id = Uuid::parse_str(&server_task_id_str).unwrap_or_default(); + let state = TaskState::from_str(&state_str).unwrap_or_default(); + let created_at = DateTime::parse_from_rfc3339(&created_at_str) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + let started_at = started_at_str + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)); + let completed_at = completed_at_str + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)); + + Ok(LocalTask { + id, + server_task_id, + state, + container_id, + overlay_path, + repo_url, + base_branch, + plan, + created_at, + started_at, + completed_at, + error_message, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_open_memory() { + let db = LocalDb::open_memory().unwrap(); + assert!(db.get_active_tasks().unwrap().is_empty()); + } + + #[test] + fn test_save_and_get_task() { + let db = LocalDb::open_memory().unwrap(); + + let task = LocalTask { + id: Uuid::new_v4(), + server_task_id: Uuid::new_v4(), + state: TaskState::Running, + container_id: Some("abc123".to_string()), + overlay_path: Some("/tmp/overlay".to_string()), + repo_url: Some("https://github.com/test/repo".to_string()), + base_branch: Some("main".to_string()), + plan: "Build the feature".to_string(), + created_at: Utc::now(), + started_at: Some(Utc::now()), + completed_at: None, + error_message: None, + }; + + db.save_task(&task).unwrap(); + + let loaded = db.get_task(task.id).unwrap().unwrap(); + assert_eq!(loaded.id, task.id); + assert_eq!(loaded.state, TaskState::Running); + assert_eq!(loaded.plan, "Build the feature"); + } + + #[test] + fn test_output_buffer() { + let db = LocalDb::open_memory().unwrap(); + let task_id = Uuid::new_v4(); + + db.buffer_output(task_id, "line 1", false).unwrap(); + db.buffer_output(task_id, "line 2", false).unwrap(); + + let unsent = db.get_unsent_outputs(10).unwrap(); + assert_eq!(unsent.len(), 2); + + let ids: Vec = unsent.iter().map(|o| o.id).collect(); + db.mark_outputs_sent(&ids).unwrap(); + + let unsent = db.get_unsent_outputs(10).unwrap(); + assert!(unsent.is_empty()); + } +} diff --git a/makima/daemon/src/db/mod.rs b/makima/daemon/src/db/mod.rs new file mode 100644 index 0000000..2c6e0f3 --- /dev/null +++ b/makima/daemon/src/db/mod.rs @@ -0,0 +1,5 @@ +//! Local database for daemon state persistence. + +pub mod local; + +pub use local::{BufferedOutput, LocalDb, LocalTask}; diff --git a/makima/daemon/src/error.rs b/makima/daemon/src/error.rs new file mode 100644 index 0000000..00e5140 --- /dev/null +++ b/makima/daemon/src/error.rs @@ -0,0 +1,75 @@ +//! Error types for the makima daemon. + +use thiserror::Error; +use uuid::Uuid; + +/// Top-level daemon error type. +#[derive(Error, Debug)] +pub enum DaemonError { + #[error("WebSocket error: {0}")] + WebSocket(#[from] tokio_tungstenite::tungstenite::Error), + + #[error("Worktree error: {0}")] + Worktree(#[from] crate::worktree::WorktreeError), + + #[error("Process error: {0}")] + Process(#[from] crate::process::ClaudeProcessError), + + #[error("Task error: {0}")] + Task(#[from] TaskError), + + #[error("Configuration error: {0}")] + Config(#[from] config::ConfigError), + + #[error("Database error: {0}")] + Database(#[from] rusqlite::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Authentication failed: {0}")] + AuthFailed(String), + + #[error("Connection lost")] + ConnectionLost, + + #[error("Server error: {code} - {message}")] + ServerError { code: String, message: String }, +} + +/// Task management errors. +#[derive(Error, Debug)] +pub enum TaskError { + #[error("Task not found: {0}")] + NotFound(Uuid), + + #[error("Invalid state transition from {from} to {to}")] + InvalidStateTransition { from: String, to: String }, + + #[error("Concurrency limit reached")] + ConcurrencyLimit, + + #[error("Task already exists: {0}")] + AlreadyExists(Uuid), + + #[error("Task not running: {0}")] + NotRunning(Uuid), + + #[error("Failed to send message to task: {0}")] + MessageFailed(String), + + #[error("Task setup failed: {0}")] + SetupFailed(String), + + #[error("Task execution failed: {0}")] + ExecutionFailed(String), +} + +/// Result type alias for daemon operations. +pub type Result = std::result::Result; + +/// Result type alias for task operations. +pub type TaskResult = std::result::Result; diff --git a/makima/daemon/src/lib.rs b/makima/daemon/src/lib.rs new file mode 100644 index 0000000..9555681 --- /dev/null +++ b/makima/daemon/src/lib.rs @@ -0,0 +1,21 @@ +//! Makima Daemon - Git worktree orchestration for Claude Code. +//! +//! This daemon runs on worker machines and: +//! - Connects to the makima server via WebSocket +//! - Creates git worktrees for task isolation +//! - Runs Claude Code CLI as subprocesses in worktrees +//! - Streams JSON output back to server + +pub mod cli; +pub mod config; +pub mod db; +pub mod error; +pub mod process; +pub mod task; +pub mod temp; +pub mod worktree; +pub mod ws; + +pub use cli::Cli; +pub use config::DaemonConfig; +pub use error::{DaemonError, Result}; diff --git a/makima/daemon/src/main.rs b/makima/daemon/src/main.rs new file mode 100644 index 0000000..e4ca5d4 --- /dev/null +++ b/makima/daemon/src/main.rs @@ -0,0 +1,313 @@ +//! Makima Daemon - Git worktree orchestration for Claude Code. + +use std::sync::Arc; + +use std::path::Path; + +use clap::Parser; +use makima_daemon::cli::Cli; +use makima_daemon::config::{DaemonConfig, RepoEntry}; +use makima_daemon::db::LocalDb; +use makima_daemon::error::DaemonError; +use makima_daemon::task::{TaskConfig, TaskManager}; +use makima_daemon::ws::{DaemonCommand, WsClient}; +use tokio::process::Command; +use tokio::sync::mpsc; +use tracing_subscriber::{fmt, prelude::*, EnvFilter}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + eprintln!("=== Makima Daemon Starting ==="); + + // Parse command-line arguments + let cli = Cli::parse(); + + // Load configuration with CLI overrides + eprintln!("[1/5] Loading configuration..."); + let config = match DaemonConfig::load_with_cli(&cli) { + Ok(cfg) => { + eprintln!(" Config loaded: server={}", cfg.server.url); + cfg + } + Err(e) => { + eprintln!("Failed to load configuration: {}", e); + eprintln!(); + eprintln!("Use CLI flags:"); + eprintln!(" makima-daemon --server-url ws://localhost:8080 --api-key your-api-key"); + eprintln!(); + eprintln!("Or set environment variables:"); + eprintln!(" MAKIMA_DAEMON_SERVER_URL=ws://localhost:8080"); + eprintln!(" MAKIMA_API_KEY=your-api-key"); + eprintln!(); + eprintln!("Or create a config file: makima-daemon.toml"); + eprintln!(); + eprintln!("Run 'makima-daemon --help' for all options."); + std::process::exit(1); + } + }; + + // Initialize logging + init_logging(&config.logging.level, &config.logging.format); + eprintln!("[2/5] Logging initialized"); + + // Initialize local database + eprintln!("[3/5] Opening local database: {}", config.local_db.path.display()); + let _local_db = LocalDb::open(&config.local_db.path)?; + eprintln!(" Database opened"); + + // Initialize worktree directories + eprintln!("[4/5] Setting up directories..."); + tokio::fs::create_dir_all(&config.worktree.base_dir).await?; + tokio::fs::create_dir_all(&config.worktree.repos_dir).await?; + tokio::fs::create_dir_all(&config.repos.home_dir).await?; + eprintln!(" Worktree base: {}", config.worktree.base_dir.display()); + eprintln!(" Repos cache: {}", config.worktree.repos_dir.display()); + eprintln!(" Home dir: {}", config.repos.home_dir.display()); + + // Auto-clone repositories if configured + if !config.repos.auto_clone.is_empty() { + eprintln!(" Auto-cloning {} repositories...", config.repos.auto_clone.len()); + for repo_entry in &config.repos.auto_clone { + if let Err(e) = auto_clone_repo(repo_entry, &config.repos.home_dir).await { + eprintln!(" WARNING: Failed to clone {}: {}", repo_entry.url(), e); + } + } + } + + // Create channels for communication + let (command_tx, mut command_rx) = mpsc::channel::(64); + + // Get machine info + let machine_id = get_machine_id(); + let hostname = get_hostname(); + eprintln!(" Machine ID: {}", machine_id); + eprintln!(" Hostname: {}", hostname); + + // Create WebSocket client + eprintln!("[5/5] Connecting to server: {}", config.server.url); + let mut ws_client = WsClient::new( + config.server.clone(), + machine_id, + hostname, + config.process.max_concurrent_tasks as i32, + command_tx, + ); + + // Get sender for task manager + let ws_tx = ws_client.sender(); + + // Create task configuration + let task_config = TaskConfig { + max_concurrent_tasks: config.process.max_concurrent_tasks, + worktree_base_dir: config.worktree.base_dir.clone(), + env_vars: config.process.env_vars.clone(), + claude_command: config.process.claude_command.clone(), + claude_args: config.process.claude_args.clone(), + claude_pre_args: config.process.claude_pre_args.clone(), + enable_permissions: config.process.enable_permissions, + disable_verbose: config.process.disable_verbose, + }; + + // Create task manager + let task_manager = Arc::new(TaskManager::new(task_config, ws_tx.clone())); + + // Spawn command handler + let task_manager_clone = task_manager.clone(); + tokio::spawn(async move { + tracing::info!("Command handler started, waiting for commands..."); + while let Some(command) = command_rx.recv().await { + tracing::info!("Received command from channel: {:?}", command); + if let Err(e) = task_manager_clone.handle_command(command).await { + tracing::error!("Failed to handle command: {}", e); + } + } + tracing::info!("Command handler stopped"); + }); + + // Handle shutdown signals + let shutdown_signal = async { + tokio::signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C handler"); + eprintln!("\nReceived shutdown signal"); + }; + + eprintln!("=== Daemon running (Ctrl+C to stop) ==="); + + // Run WebSocket client with shutdown handling + tokio::select! { + result = ws_client.run() => { + match result { + Ok(()) => eprintln!("WebSocket client exited cleanly"), + Err(DaemonError::AuthFailed(msg)) => { + eprintln!("ERROR: Authentication failed: {}", msg); + std::process::exit(1); + } + Err(e) => { + eprintln!("ERROR: WebSocket client error: {}", e); + std::process::exit(1); + } + } + } + _ = shutdown_signal => { + eprintln!("Shutting down..."); + } + } + + // Cleanup + tracing::info!("Daemon stopped"); + + Ok(()) +} + +fn init_logging(level: &str, format: &str) { + let filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(level)) + .unwrap_or_else(|_| EnvFilter::new("info")); + + let subscriber = tracing_subscriber::registry().with(filter); + + if format == "json" { + subscriber.with(fmt::layer().json()).init(); + } else { + subscriber.with(fmt::layer()).init(); + } +} + +fn get_machine_id() -> String { + // Try to read machine-id from standard locations + #[cfg(target_os = "linux")] + { + if let Ok(id) = std::fs::read_to_string("/etc/machine-id") { + return id.trim().to_string(); + } + if let Ok(id) = std::fs::read_to_string("/var/lib/dbus/machine-id") { + return id.trim().to_string(); + } + } + + #[cfg(target_os = "macos")] + { + // Use IOPlatformSerialNumber + if let Ok(output) = std::process::Command::new("ioreg") + .args(["-rd1", "-c", "IOPlatformExpertDevice"]) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.contains("IOPlatformUUID") { + if let Some(uuid) = line.split('"').nth(3) { + return uuid.to_string(); + } + } + } + } + } + + // Fallback: generate a random ID and persist it + let state_dir = dirs_next::data_local_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("makima-daemon"); + let machine_id_file = state_dir.join("machine-id"); + + if let Ok(id) = std::fs::read_to_string(&machine_id_file) { + return id.trim().to_string(); + } + + // Generate new ID + let new_id = uuid::Uuid::new_v4().to_string(); + std::fs::create_dir_all(&state_dir).ok(); + std::fs::write(&machine_id_file, &new_id).ok(); + new_id +} + +fn get_hostname() -> String { + hostname::get() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_else(|_| "unknown".to_string()) +} + +/// Auto-clone a repository to the home directory if it doesn't exist. +async fn auto_clone_repo( + repo_entry: &RepoEntry, + home_dir: &Path, +) -> Result<(), Box> { + let dir_name = repo_entry + .dir_name() + .ok_or("Could not determine directory name from URL")?; + let target_dir = home_dir.join(&dir_name); + + // Check if already cloned + if target_dir.exists() { + eprintln!(" [skip] {} (already exists)", dir_name); + return Ok(()); + } + + let url = repo_entry.expanded_url(); + eprintln!(" [clone] {} -> {}", url, target_dir.display()); + + // Build git clone command + let mut args = vec!["clone".to_string()]; + + // Add shallow clone if requested + if repo_entry.shallow() { + args.push("--depth".to_string()); + args.push("1".to_string()); + } + + // Add branch if specified + if let Some(branch) = repo_entry.branch() { + args.push("--branch".to_string()); + args.push(branch.to_string()); + } + + args.push(url.clone()); + args.push(target_dir.to_string_lossy().to_string()); + + // Run git clone + let output = Command::new("git") + .args(&args) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git clone failed: {}", stderr).into()); + } + + eprintln!(" [done] {}", dir_name); + Ok(()) +} + +/// dirs_next minimal replacement +mod dirs_next { + use std::path::PathBuf; + + pub fn data_local_dir() -> Option { + #[cfg(target_os = "macos")] + { + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join("Library").join("Application Support")) + } + #[cfg(target_os = "linux")] + { + std::env::var("XDG_DATA_HOME") + .ok() + .map(PathBuf::from) + .or_else(|| { + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join(".local").join("share")) + }) + } + #[cfg(target_os = "windows")] + { + std::env::var("LOCALAPPDATA").ok().map(PathBuf::from) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + None + } + } +} diff --git a/makima/daemon/src/process/claude.rs b/makima/daemon/src/process/claude.rs new file mode 100644 index 0000000..e06ee09 --- /dev/null +++ b/makima/daemon/src/process/claude.rs @@ -0,0 +1,481 @@ +//! Claude Code process management. + +use std::collections::HashMap; +use std::path::Path; +use std::process::Stdio; +use std::sync::Arc; + +use futures::Stream; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, Command}; +use tokio::sync::{mpsc, Mutex}; + +use super::claude_protocol::ClaudeInputMessage; + +/// Errors that can occur during Claude process management. +#[derive(Debug, thiserror::Error)] +pub enum ClaudeProcessError { + #[error("Failed to spawn Claude process: {0}")] + SpawnFailed(#[from] std::io::Error), + + #[error("Claude command not found: {0}")] + CommandNotFound(String), + + #[error("Process already exited")] + AlreadyExited, + + #[error("Failed to read output: {0}")] + OutputRead(String), +} + +/// A line of output from Claude Code. +#[derive(Debug, Clone)] +pub struct OutputLine { + /// The raw content of the line. + pub content: String, + /// Whether this is from stdout (true) or stderr (false). + pub is_stdout: bool, + /// Parsed JSON type if available (e.g., "system", "assistant", "result"). + pub json_type: Option, +} + +impl OutputLine { + /// Create a new stdout output line. + pub fn stdout(content: String) -> Self { + let json_type = extract_json_type(&content); + Self { + content, + is_stdout: true, + json_type, + } + } + + /// Create a new stderr output line. + pub fn stderr(content: String) -> Self { + Self { + content, + is_stdout: false, + json_type: None, + } + } +} + +/// Extract the "type" field from a JSON line if present. +fn extract_json_type(line: &str) -> Option { + // Quick check for JSON + if !line.starts_with('{') { + return None; + } + + // Try to parse and extract type + if let Ok(json) = serde_json::from_str::(line) { + json.get("type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } else { + None + } +} + +/// Handle to a running Claude Code process. +pub struct ClaudeProcess { + /// The child process. + child: Child, + /// Receiver for output lines. + output_rx: mpsc::Receiver, + /// Stdin handle for sending input to the process (thread-safe). + stdin: Arc>>, +} + +impl ClaudeProcess { + /// Wait for the process to exit and return the exit code. + pub async fn wait(&mut self) -> Result { + let status = self.child.wait().await?; + Ok(status.code().unwrap_or(-1) as i64) + } + + /// Check if the process has exited. + pub fn try_wait(&mut self) -> Result, ClaudeProcessError> { + match self.child.try_wait()? { + Some(status) => Ok(Some(status.code().unwrap_or(-1) as i64)), + None => Ok(None), + } + } + + /// Kill the process. + pub async fn kill(&mut self) -> Result<(), ClaudeProcessError> { + self.child.kill().await?; + Ok(()) + } + + /// Get the next output line, if available. + pub async fn next_output(&mut self) -> Option { + self.output_rx.recv().await + } + + /// Send a raw message to the process via stdin. + /// + /// This can be used to provide input when Claude Code is waiting for user input. + pub async fn send_input(&self, message: &str) -> Result<(), ClaudeProcessError> { + let mut stdin_guard = self.stdin.lock().await; + if let Some(ref mut stdin) = *stdin_guard { + stdin + .write_all(message.as_bytes()) + .await + .map_err(|e| ClaudeProcessError::OutputRead(format!("Failed to write to stdin: {}", e)))?; + stdin + .write_all(b"\n") + .await + .map_err(|e| ClaudeProcessError::OutputRead(format!("Failed to write newline: {}", e)))?; + stdin + .flush() + .await + .map_err(|e| ClaudeProcessError::OutputRead(format!("Failed to flush stdin: {}", e)))?; + Ok(()) + } else { + Err(ClaudeProcessError::OutputRead("Stdin not available".to_string())) + } + } + + /// Send a user message to the process via stdin using JSON protocol. + /// + /// This is the preferred method when using `--input-format=stream-json`. + /// The message is serialized as JSON and sent as a single line. + pub async fn send_user_message(&self, content: &str) -> Result<(), ClaudeProcessError> { + let message = ClaudeInputMessage::user(content); + let json_line = message.to_json_line().map_err(|e| { + ClaudeProcessError::OutputRead(format!("Failed to serialize message: {}", e)) + })?; + + tracing::debug!(content_len = content.len(), "Sending user message to Claude process"); + + let mut stdin_guard = self.stdin.lock().await; + if let Some(ref mut stdin) = *stdin_guard { + stdin + .write_all(json_line.as_bytes()) + .await + .map_err(|e| ClaudeProcessError::OutputRead(format!("Failed to write to stdin: {}", e)))?; + stdin + .flush() + .await + .map_err(|e| ClaudeProcessError::OutputRead(format!("Failed to flush stdin: {}", e)))?; + Ok(()) + } else { + Err(ClaudeProcessError::OutputRead("Stdin not available".to_string())) + } + } + + /// Get a clone of the stdin handle for external use. + pub fn stdin_handle(&self) -> Arc>> { + Arc::clone(&self.stdin) + } + + /// Close stdin, signaling EOF to the process. + pub async fn close_stdin(&self) -> Result<(), ClaudeProcessError> { + let mut stdin_guard = self.stdin.lock().await; + if let Some(mut stdin) = stdin_guard.take() { + let _ = stdin.shutdown().await; + } + Ok(()) + } + + /// Convert to a stream of output lines. + pub fn into_stream(self) -> impl Stream { + futures::stream::unfold(self.output_rx, |mut rx| async move { + rx.recv().await.map(|line| (line, rx)) + }) + } +} + +/// Manages Claude Code process spawning. +pub struct ProcessManager { + /// Path to the claude command. + claude_command: String, + /// Additional arguments to pass to Claude Code (after defaults). + claude_args: Vec, + /// Arguments to pass before defaults. + claude_pre_args: Vec, + /// Whether to enable Claude's permission system (skip --dangerously-skip-permissions). + enable_permissions: bool, + /// Whether to disable verbose output. + disable_verbose: bool, + /// Default environment variables to pass. + default_env: HashMap, +} + +impl Default for ProcessManager { + fn default() -> Self { + Self::new() + } +} + +impl ProcessManager { + /// Create a new ProcessManager with default settings. + pub fn new() -> Self { + Self { + claude_command: "claude".to_string(), + claude_args: Vec::new(), + claude_pre_args: Vec::new(), + enable_permissions: false, + disable_verbose: false, + default_env: HashMap::new(), + } + } + + /// Create a ProcessManager with a custom claude command path. + pub fn with_command(command: String) -> Self { + Self { + claude_command: command, + claude_args: Vec::new(), + claude_pre_args: Vec::new(), + enable_permissions: false, + disable_verbose: false, + default_env: HashMap::new(), + } + } + + /// Set additional arguments to pass after default arguments. + pub fn with_args(mut self, args: Vec) -> Self { + self.claude_args = args; + self + } + + /// Set arguments to pass before default arguments. + pub fn with_pre_args(mut self, args: Vec) -> Self { + self.claude_pre_args = args; + self + } + + /// Enable Claude's permission system (don't pass --dangerously-skip-permissions). + pub fn with_permissions_enabled(mut self, enabled: bool) -> Self { + self.enable_permissions = enabled; + self + } + + /// Disable verbose output. + pub fn with_verbose_disabled(mut self, disabled: bool) -> Self { + self.disable_verbose = disabled; + self + } + + /// Add default environment variables. + pub fn with_env(mut self, env: HashMap) -> Self { + self.default_env = env; + self + } + + /// Spawn a Claude Code process to execute a plan. + /// + /// The process runs in the specified working directory with stream-json output format. + pub async fn spawn( + &self, + working_dir: &Path, + plan: &str, + extra_env: Option>, + ) -> Result { + tracing::info!( + working_dir = %working_dir.display(), + plan_len = plan.len(), + plan_preview = %if plan.len() > 200 { &plan[..200] } else { plan }, + "Spawning Claude Code process" + ); + + // Verify working directory exists + if !working_dir.exists() { + tracing::error!(working_dir = %working_dir.display(), "Working directory does not exist!"); + return Err(ClaudeProcessError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Working directory does not exist: {}", working_dir.display()), + ))); + } + + // Build environment + let mut env = self.default_env.clone(); + if let Some(extra) = extra_env { + env.extend(extra); + } + + // Build arguments list + let mut args = Vec::new(); + + // Pre-args (before defaults) + args.extend(self.claude_pre_args.clone()); + + // Required arguments for stream-json protocol + args.push("--output-format=stream-json".to_string()); + args.push("--input-format=stream-json".to_string()); + + // Optional default arguments + if !self.disable_verbose { + args.push("--verbose".to_string()); + } + if !self.enable_permissions { + args.push("--dangerously-skip-permissions".to_string()); + } + + // Additional user-configured arguments + args.extend(self.claude_args.clone()); + + tracing::debug!(args = ?args, "Claude command arguments"); + + // Spawn the process + let mut child = Command::new(&self.claude_command) + .args(&args) + .current_dir(working_dir) + .envs(env) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + ClaudeProcessError::CommandNotFound(self.claude_command.clone()) + } else { + ClaudeProcessError::SpawnFailed(e) + } + })?; + + // Create output channel + let (tx, rx) = mpsc::channel(1000); + + // Take stdout, stderr, and stdin + // With --input-format=stream-json, we keep stdin open for sending messages + let stdin = child.stdin.take(); + let stdin = Arc::new(Mutex::new(stdin)); + + let stdout = child.stdout.take().expect("stdout should be piped"); + let stderr = child.stderr.take().expect("stderr should be piped"); + + // Spawn task to read stdout + let tx_stdout = tx.clone(); + tokio::spawn(async move { + use tokio::io::AsyncReadExt; + let mut reader = BufReader::new(stdout); + let mut buffer = vec![0u8; 4096]; + let mut line_buffer = String::new(); + + loop { + // Try to read with a timeout to detect if we're stuck + match tokio::time::timeout( + tokio::time::Duration::from_secs(5), + reader.read(&mut buffer) + ).await { + Ok(Ok(0)) => { + // EOF + tracing::debug!("Claude stdout EOF"); + // Send any remaining content + if !line_buffer.is_empty() { + let _ = tx_stdout.send(OutputLine::stdout(line_buffer)).await; + } + break; + } + Ok(Ok(n)) => { + let chunk = String::from_utf8_lossy(&buffer[..n]); + tracing::debug!(bytes = n, chunk_preview = %if chunk.len() > 100 { &chunk[..100] } else { &chunk }, "Got stdout chunk from Claude"); + + // Accumulate into line buffer and emit complete lines + line_buffer.push_str(&chunk); + while let Some(newline_pos) = line_buffer.find('\n') { + let line = line_buffer[..newline_pos].to_string(); + line_buffer = line_buffer[newline_pos + 1..].to_string(); + if tx_stdout.send(OutputLine::stdout(line)).await.is_err() { + return; + } + } + } + Ok(Err(e)) => { + tracing::error!(error = %e, "Error reading Claude stdout"); + break; + } + Err(_) => { + // Timeout - no data for 5 seconds + tracing::warn!("No stdout data from Claude for 5 seconds"); + } + } + } + tracing::debug!("Claude stdout reader task ended"); + }); + + // Spawn task to read stderr + let tx_stderr = tx; + tokio::spawn(async move { + let reader = BufReader::new(stderr); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + tracing::debug!(line = %line, "Claude stderr"); + if tx_stderr.send(OutputLine::stderr(line)).await.is_err() { + break; + } + } + tracing::debug!("Claude stderr reader task ended"); + }); + + tracing::info!("Claude Code process spawned successfully"); + + let process = ClaudeProcess { + child, + output_rx: rx, + stdin, + }; + + // Send the initial plan as the first user message + tracing::info!(plan_len = plan.len(), "Sending initial plan to Claude via stdin"); + process.send_user_message(plan).await?; + + Ok(process) + } + + /// Check if the claude command is available. + pub async fn check_claude_available(&self) -> Result { + let output = Command::new(&self.claude_command) + .arg("--version") + .output() + .await + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + ClaudeProcessError::CommandNotFound(self.claude_command.clone()) + } else { + ClaudeProcessError::SpawnFailed(e) + } + })?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + Err(ClaudeProcessError::CommandNotFound( + self.claude_command.clone(), + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_json_type() { + assert_eq!( + extract_json_type(r#"{"type":"system","subtype":"init"}"#), + Some("system".to_string()) + ); + assert_eq!( + extract_json_type(r#"{"type":"assistant","message":{}}"#), + Some("assistant".to_string()) + ); + assert_eq!(extract_json_type("not json"), None); + assert_eq!(extract_json_type(r#"{"no_type": true}"#), None); + } + + #[test] + fn test_output_line_creation() { + let line = OutputLine::stdout(r#"{"type":"result"}"#.to_string()); + assert!(line.is_stdout); + assert_eq!(line.json_type, Some("result".to_string())); + + let line = OutputLine::stderr("error message".to_string()); + assert!(!line.is_stdout); + assert_eq!(line.json_type, None); + } +} diff --git a/makima/daemon/src/process/claude_protocol.rs b/makima/daemon/src/process/claude_protocol.rs new file mode 100644 index 0000000..930152b --- /dev/null +++ b/makima/daemon/src/process/claude_protocol.rs @@ -0,0 +1,59 @@ +//! Claude Code JSON protocol types for stdin communication. +//! +//! When using `--input-format=stream-json`, Claude Code expects +//! newline-delimited JSON messages on stdin. + +use serde::Serialize; + +/// Message sent to Claude Code via stdin. +/// +/// Format based on Claude Code's stream-json input protocol. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClaudeInputMessage { + /// A user message to send to Claude. + User { message: UserMessage }, +} + +/// The inner user message structure. +#[derive(Debug, Clone, Serialize)] +pub struct UserMessage { + /// Always "user" for user messages. + pub role: String, + /// The message content. + pub content: String, +} + +impl ClaudeInputMessage { + /// Create a new user message. + pub fn user(content: impl Into) -> Self { + Self::User { + message: UserMessage { + role: "user".to_string(), + content: content.into(), + }, + } + } + + /// Serialize to a JSON string with trailing newline (NDJSON format). + pub fn to_json_line(&self) -> Result { + let mut json = serde_json::to_string(self)?; + json.push('\n'); + Ok(json) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_user_message_serialization() { + let msg = ClaudeInputMessage::user("Hello, Claude!"); + let json = msg.to_json_line().unwrap(); + + // Should produce: {"type":"user","message":{"role":"user","content":"Hello, Claude!"}}\n + assert!(json.starts_with(r#"{"type":"user","message":{"role":"user","content":"Hello, Claude!"}}"#)); + assert!(json.ends_with('\n')); + } +} diff --git a/makima/daemon/src/process/mod.rs b/makima/daemon/src/process/mod.rs new file mode 100644 index 0000000..814a3c5 --- /dev/null +++ b/makima/daemon/src/process/mod.rs @@ -0,0 +1,10 @@ +//! Process management for Claude Code subprocess execution. +//! +//! Spawns and manages Claude Code processes in worktree directories, +//! streaming JSON output back to the daemon. + +mod claude; +mod claude_protocol; + +pub use claude::{ClaudeProcess, ClaudeProcessError, OutputLine, ProcessManager}; +pub use claude_protocol::ClaudeInputMessage; diff --git a/makima/daemon/src/task/manager.rs b/makima/daemon/src/task/manager.rs new file mode 100644 index 0000000..4979ce7 --- /dev/null +++ b/makima/daemon/src/task/manager.rs @@ -0,0 +1,2248 @@ +//! Task lifecycle manager using git worktrees and Claude Code subprocesses. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; + +use rand::Rng; +use tokio::io::AsyncWriteExt; +use tokio::sync::{mpsc, RwLock, Semaphore}; +use uuid::Uuid; + +use std::collections::HashSet; + +use super::state::TaskState; +use crate::error::{DaemonError, TaskError, TaskResult}; +use crate::process::{ClaudeInputMessage, ProcessManager}; +use crate::temp::TempManager; +use crate::worktree::{is_new_repo_request, ConflictResolution, WorktreeInfo, WorktreeManager}; +use crate::ws::{BranchInfo, DaemonCommand, DaemonMessage}; + +/// Generate a secure random API key for orchestrator tool access. +fn generate_tool_key() -> String { + let mut rng = rand::rng(); + let bytes: [u8; 32] = rng.random(); + hex::encode(bytes) +} + +/// System prompt for regular (non-orchestrator) subtasks. +/// This ensures subtasks work only within their isolated worktree directory. +const SUBTASK_SYSTEM_PROMPT: &str = r#"You are working in an isolated worktree directory that contains a snapshot of the codebase. + +## IMPORTANT: Directory Restrictions + +**You MUST only work within the current working directory (your worktree).** + +- DO NOT use `cd` to navigate to directories outside your worktree +- DO NOT use absolute paths that point outside your worktree (e.g., don't write to ~/some/path, /tmp, or the original repository) +- DO NOT modify files in parent directories or sibling directories +- All your file operations should be relative to the current directory + +Your working directory is your sandboxed workspace. When you complete your task, your changes will be reviewed and integrated by the orchestrator. + +**Why?** Your worktree is isolated so that: +1. Your changes don't affect other running tasks +2. Changes can be reviewed before integration +3. Multiple tasks can work on the codebase in parallel without conflicts + +--- + +"#; + +/// The orchestrator system prompt that tells Claude how to use the helper script. +const ORCHESTRATOR_SYSTEM_PROMPT: &str = r#"You are an orchestrator task. Your job is to coordinate subtasks and integrate their work, NOT to write code directly. + +## FIRST STEP + +Start by checking if you have existing subtasks: + +```bash +# List all subtasks to see what work needs to be done +./.makima/orchestrate.sh list +``` + +If subtasks exist, start them. If you need additional subtasks or no subtasks exist yet, you can create them. + +--- + +## Creating Subtasks + +You can create new subtasks to break down work: + +```bash +# Create a new subtask with a name and plan +./.makima/orchestrate.sh create "Subtask Name" "Detailed plan for what the subtask should do..." + +# The command returns the new subtask ID - use it to start the subtask +./.makima/orchestrate.sh start +``` + +Create subtasks when you need to: +- Break down complex work into smaller pieces +- Run multiple tasks in parallel on different parts of the codebase +- Delegate specific implementation work + +## Task Continuation (Sequential Dependencies) + +When subtasks need to build on each other's work (e.g., Task B depends on Task A's changes), use `--continue-from`: + +```bash +# Create Task B that continues from Task A's worktree +./.makima/orchestrate.sh create "Task B" "Build on Task A's work..." --continue-from +``` + +This copies all files from Task A's worktree into Task B's worktree, so Task B starts with Task A's changes. + +**When to use continuation:** +- Sequential work: Task B needs Task A's output files +- Staged implementation: Building features incrementally +- Fix-and-extend: One task fixes issues, another adds features on top + +**When NOT to use continuation:** +- Parallel tasks working on different files +- Independent subtasks that can be merged separately + +**Important for merging:** When tasks continue from each other, only merge the FINAL task in the chain. Earlier tasks' changes are already included in later tasks' worktrees. + +## Sharing Files with Subtasks + +Use `--files` to copy specific files from your orchestrator worktree to subtasks. This is useful for sharing plans, configs, or data files: + +```bash +# Create subtask with specific files copied from orchestrator +./.makima/orchestrate.sh create "Implement Feature" "Follow the plan in PLAN.md" --files "PLAN.md" + +# Copy multiple files (comma-separated) +./.makima/orchestrate.sh create "API Work" "Use the spec..." --files "PLAN.md,api-spec.yaml,types.ts" + +# Combine with --continue-from to share files AND continue from another task +./.makima/orchestrate.sh create "Step 2" "Continue..." --continue-from --files "requirements.md" +``` + +**Use cases for --files:** +- Share a PLAN.md with detailed implementation steps +- Distribute configuration or spec files +- Pass generated data or intermediate results + +## How Subtasks Work + +Each subtask runs in its own **worktree** - a separate directory with a copy of the codebase. When subtasks complete: +- Their work remains in the worktree files (NOT committed to git) +- **Subtasks do NOT auto-merge** - YOU must integrate their work into your worktree +- You can view and copy files from subtask worktrees using their paths +- The worktree path is returned when you get subtask status + +**IMPORTANT:** Subtasks never create PRs or merge to the target repository. Only the orchestrator (you) can trigger completion actions like PR creation or merging after integrating all subtask work. + +## Subtask Commands +```bash +# List all subtasks and their current status +./.makima/orchestrate.sh list + +# Create a new subtask (returns the subtask ID) +./.makima/orchestrate.sh create "Name" "Plan/description" + +# Create a subtask that continues from another task's worktree +./.makima/orchestrate.sh create "Name" "Plan" --continue-from + +# Create a subtask with specific files copied from orchestrator worktree +./.makima/orchestrate.sh create "Name" "Plan" --files "file1.md,file2.yaml" + +# Start a specific subtask (it will run in its own Claude instance) +./.makima/orchestrate.sh start + +# Stop a running subtask +./.makima/orchestrate.sh stop + +# Get detailed status of a subtask (includes worktree_path when available) +./.makima/orchestrate.sh status + +# Get the output/logs of a subtask +./.makima/orchestrate.sh output + +# Get the worktree path for a subtask +./.makima/orchestrate.sh worktree +``` + +## Integrating Subtask Work + +When subtasks complete, their changes exist as files in their worktree directories: +- Files are NOT committed to git branches +- You must copy/integrate files from subtask worktrees into your worktree +- Use standard file operations (cp, cat, etc.) to review and integrate changes + +### Handling Continuation Chains + +**CRITICAL:** When subtasks use `--continue-from`, they form a chain where each task includes all changes from previous tasks. You must ONLY integrate the FINAL task in each chain. + +Example chain: Task A → Task B (continues from A) → Task C (continues from B) +- Task C's worktree contains ALL changes from A, B, and C +- You should ONLY integrate Task C's worktree +- DO NOT integrate Task A or Task B separately (their changes are already in C) + +**How to track continuation chains:** +1. When you create tasks with `--continue-from`, note which task continues from which +2. Build a mental model: Independent tasks (no continuation) + Continuation chains +3. For each chain, only integrate the LAST task in the chain + +**Example with mixed independent and chained tasks:** +``` +Independent tasks (integrate all): +- Task X: API endpoints +- Task Y: Database models + +Continuation chain (integrate ONLY the last one): +- Task A: Core feature → Task B: Tests (continues from A) → Task C: Docs (continues from B) + Only integrate Task C! +``` + +### Integration Examples + +For independent subtasks (no continuation): +```bash +# Get the worktree path for a completed subtask +SUBTASK_PATH=$(./.makima/orchestrate.sh worktree ) + +# View what files were changed +ls -la "$SUBTASK_PATH" +diff -r . "$SUBTASK_PATH" --exclude=.git --exclude=.makima + +# Copy specific files from subtask +cp "$SUBTASK_PATH/src/new_file.rs" ./src/ +cp "$SUBTASK_PATH/src/modified_file.rs" ./src/ + +# Or use diff/patch for more control +diff -u ./src/file.rs "$SUBTASK_PATH/src/file.rs" > changes.patch +patch -p0 < changes.patch +``` + +For continuation chains (only integrate the final task): +```bash +# If you have: Task A → Task B → Task C (each continues from previous) +# ONLY get and integrate Task C's worktree - it has everything! + +FINAL_TASK_PATH=$(./.makima/orchestrate.sh worktree ) + +# Copy all changes from the final task +rsync -av --exclude='.git' --exclude='.makima' "$FINAL_TASK_PATH/" ./ +``` + +## Completion +```bash +# Mark yourself as complete after integrating all subtask work +./.makima/orchestrate.sh done "Summary of what was accomplished" +``` + +## Workflow +1. **List existing subtasks**: Run `list` to see current subtasks +2. **Create subtasks if needed**: Use `create` to add new subtasks for the work + - For independent parallel work: create without `--continue-from` + - For sequential dependencies: use `--continue-from ` + - Track which tasks continue from which (continuation chains) +3. **Start subtasks**: Run `start` for each subtask +4. **Monitor progress**: Check status and output as subtasks run +5. **Integrate work**: When subtasks complete: + - For independent tasks: integrate each one's worktree + - For continuation chains: ONLY integrate the FINAL task (it has all changes) + - Get worktree path with `worktree ` + - Copy or merge files into your worktree +6. **Complete**: Call `done` once all work is integrated + +## Important Notes +- Subtask files are in worktrees, NOT committed git branches +- **Subtasks do NOT auto-merge or create PRs** - you must integrate their work +- You can read files from subtask worktrees using their paths +- Use standard file tools (cp, diff, cat, rsync) to integrate changes +- You should NOT edit files directly - that's what subtasks are for +- DO NOT DO THE SUBTASKS' WORK! Your only job is to coordinate, not implement. +- When you call `done`, YOUR worktree may be used for the final PR/merge +"#; + +/// Content of the helper bash script that orchestrators use to call the API. +const ORCHESTRATE_SCRIPT: &str = r#"#!/bin/bash +# Makima Orchestrator Helper Script +# Usage: ./orchestrate.sh [args...] + +API_URL="${MAKIMA_API_URL:-http://localhost:8080}" +API_KEY="${MAKIMA_API_KEY}" +TASK_ID="${MAKIMA_TASK_ID}" + +if [ -z "$API_KEY" ]; then + echo "Error: MAKIMA_API_KEY not set" >&2 + exit 1 +fi + +if [ -z "$TASK_ID" ]; then + echo "Error: MAKIMA_TASK_ID not set" >&2 + exit 1 +fi + +# Helper function to make API calls and check for errors +api_call() { + local method="$1" + local url="$2" + local data="$3" + local response + local http_code + + if [ -n "$data" ]; then + response=$(curl -s -w "\n%{http_code}" -X "$method" \ + -H "X-Makima-Tool-Key: $API_KEY" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "$url") + else + response=$(curl -s -w "\n%{http_code}" -X "$method" \ + -H "X-Makima-Tool-Key: $API_KEY" \ + "$url") + fi + + # Extract HTTP code (last line) and body (everything else) + http_code=$(echo "$response" | tail -n1) + body=$(echo "$response" | sed '$d') + + # Check for curl errors or non-2xx status + if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then + echo "Error: API request failed with HTTP $http_code" >&2 + echo "URL: $url" >&2 + echo "Response: $body" >&2 + echo "$body" + return 1 + fi + + echo "$body" + return 0 +} + +case "$1" in + list) + api_call GET "$API_URL/api/v1/mesh/tasks/$TASK_ID/subtasks" + ;; + create) + # Parse arguments: create "name" "plan" [--continue-from ] [--files "file1,file2"] + if [ -z "$2" ] || [ -z "$3" ]; then + echo "Usage: $0 create \"\" \"\" [--continue-from ] [--files \"file1,file2\"]" >&2 + exit 1 + fi + NAME="$2" + PLAN="$3" + CONTINUE_FROM="" + COPY_FILES="" + + # Parse optional flags (can be in any order after name and plan) + shift 3 + while [ $# -gt 0 ]; do + case "$1" in + --continue-from) + CONTINUE_FROM="$2" + shift 2 + ;; + --files) + COPY_FILES="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac + done + + # Escape quotes in name and plan for JSON + NAME_ESCAPED=$(echo "$NAME" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g') + PLAN_ESCAPED=$(echo "$PLAN" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g') + + # Build JSON body + JSON_BODY="{\"name\":\"$NAME_ESCAPED\",\"plan\":\"$PLAN_ESCAPED\",\"parentTaskId\":\"$TASK_ID\"" + + if [ -n "$CONTINUE_FROM" ]; then + echo "Creating subtask: $NAME (continuing from $CONTINUE_FROM)..." >&2 + JSON_BODY="$JSON_BODY,\"continueFromTaskId\":\"$CONTINUE_FROM\"" + else + echo "Creating subtask: $NAME..." >&2 + fi + + if [ -n "$COPY_FILES" ]; then + # Convert comma-separated file list to JSON array + FILES_JSON="[" + first=true + IFS=',' read -ra FILE_ARRAY <<< "$COPY_FILES" + for file in "${FILE_ARRAY[@]}"; do + file=$(echo "$file" | xargs) # trim whitespace + if [ "$first" = true ]; then + FILES_JSON="$FILES_JSON\"$file\"" + first=false + else + FILES_JSON="$FILES_JSON,\"$file\"" + fi + done + FILES_JSON="$FILES_JSON]" + JSON_BODY="$JSON_BODY,\"copyFiles\":$FILES_JSON" + echo " (copying files: $COPY_FILES)" >&2 + fi + + JSON_BODY="$JSON_BODY}" + api_call POST "$API_URL/api/v1/mesh/tasks" "$JSON_BODY" + ;; + start) + if [ -z "$2" ]; then + echo "Usage: $0 start " >&2 + exit 1 + fi + echo "Starting subtask $2..." >&2 + api_call POST "$API_URL/api/v1/mesh/tasks/$2/start" + ;; + stop) + if [ -z "$2" ]; then + echo "Usage: $0 stop " >&2 + exit 1 + fi + api_call POST "$API_URL/api/v1/mesh/tasks/$2/stop" + ;; + status) + if [ -z "$2" ]; then + echo "Usage: $0 status " >&2 + exit 1 + fi + api_call GET "$API_URL/api/v1/mesh/tasks/$2" + ;; + output) + if [ -z "$2" ]; then + echo "Usage: $0 output " >&2 + exit 1 + fi + api_call GET "$API_URL/api/v1/mesh/tasks/$2/output" + ;; + worktree) + if [ -z "$2" ]; then + echo "Usage: $0 worktree " >&2 + exit 1 + fi + # Get the worktree path from the task's overlayPath field via API + TASK_JSON=$(api_call GET "$API_URL/api/v1/mesh/tasks/$2") + if [ $? -ne 0 ]; then + echo "Error: Failed to get task info" >&2 + exit 1 + fi + WORKTREE_PATH=$(echo "$TASK_JSON" | grep -o '"overlayPath":"[^"]*"' | cut -d'"' -f4) + if [ -z "$WORKTREE_PATH" ]; then + echo "Error: Task has no worktree path (may not have started yet)" >&2 + exit 1 + fi + if [ -d "$WORKTREE_PATH" ]; then + echo "$WORKTREE_PATH" + else + echo "Error: Worktree not found at $WORKTREE_PATH" >&2 + echo "The worktree may have been cleaned up." >&2 + exit 1 + fi + ;; + done) + SUMMARY="${2:-Task completed}" + api_call PUT "$API_URL/api/v1/mesh/tasks/$TASK_ID" "{\"status\":\"done\",\"progressSummary\":\"$SUMMARY\"}" + ;; + *) + echo "Makima Orchestrator Helper" + echo "" + echo "Usage: $0 [args...]" + echo "" + echo "Subtask Commands:" + echo " list List all subtasks and their status" + echo " create \"\" \"\" Create a new subtask" + echo " create \"...\" --continue-from ID Create subtask continuing from another task's worktree" + echo " create \"...\" --files \"file1,file2\" Copy specific files from parent (orchestrator) worktree" + echo " start Start a subtask" + echo " stop Stop a running subtask" + echo " status Get detailed subtask status" + echo " output Get subtask output history" + echo " worktree Get path to subtask's worktree" + echo "" + echo "Completion:" + echo " done [summary] Mark orchestrator as complete" + echo "" + echo "Examples:" + echo " create \"Fix bug\" \"Fix the null check bug\" --files \"PLAN.md\"" + echo " create \"Step 2\" \"Continue work\" --continue-from abc123 --files \"shared.rs,types.rs\"" + ;; +esac +"#; + +/// Tracks merge state for an orchestrator task. +#[derive(Default)] +struct MergeTracker { + /// Subtask branches that have been successfully merged. + merged_subtasks: HashSet, + /// Subtask branches that were explicitly skipped (with reason). + skipped_subtasks: HashMap, +} + +/// Managed task information. +pub struct ManagedTask { + /// Task ID. + pub id: Uuid, + /// Current state. + pub state: TaskState, + /// Worktree info if created. + pub worktree: Option, + /// Task plan. + pub plan: String, + /// Repository URL or path. + pub repo_source: Option, + /// Base branch. + pub base_branch: Option, + /// Target branch to merge into. + pub target_branch: Option, + /// Parent task ID if this is a subtask. + pub parent_task_id: Option, + /// Depth in task hierarchy (0=top-level, 1=subtask, 2=sub-subtask). + pub depth: i32, + /// Whether this task runs as an orchestrator (coordinates subtasks). + pub is_orchestrator: bool, + /// Path to target repository for completion actions. + pub target_repo_path: Option, + /// Completion action: "none", "branch", "merge", "pr". + pub completion_action: Option, + /// Task ID to continue from (copy worktree from this task). + pub continue_from_task_id: Option, + /// Files to copy from parent task's worktree. + pub copy_files: Option>, + /// Time task was created. + pub created_at: Instant, + /// Time task started running. + pub started_at: Option, + /// Time task completed. + pub completed_at: Option, + /// Error message if failed. + pub error: Option, +} + +/// Configuration for task execution. +#[derive(Clone)] +pub struct TaskConfig { + /// Maximum concurrent tasks. + pub max_concurrent_tasks: u32, + /// Base directory for worktrees. + pub worktree_base_dir: PathBuf, + /// Environment variables to pass to Claude. + pub env_vars: HashMap, + /// Claude command path. + pub claude_command: String, + /// Additional arguments to pass to Claude Code. + pub claude_args: Vec, + /// Arguments to pass before defaults. + pub claude_pre_args: Vec, + /// Enable Claude's permission system. + pub enable_permissions: bool, + /// Disable verbose output. + pub disable_verbose: bool, +} + +impl Default for TaskConfig { + fn default() -> Self { + Self { + max_concurrent_tasks: 4, + worktree_base_dir: WorktreeManager::default_base_dir(), + env_vars: HashMap::new(), + claude_command: "claude".to_string(), + claude_args: Vec::new(), + claude_pre_args: Vec::new(), + enable_permissions: false, + disable_verbose: false, + } + } +} + +/// Task manager for handling task lifecycle. +pub struct TaskManager { + /// Worktree manager. + worktree_manager: Arc, + /// Process manager. + process_manager: Arc, + /// Temp directory manager. + temp_manager: Arc, + /// Task configuration. + #[allow(dead_code)] + config: TaskConfig, + /// Active tasks. + tasks: Arc>>, + /// Channel to send messages to server. + ws_tx: mpsc::Sender, + /// Semaphore for limiting concurrent tasks. + semaphore: Arc, + /// Channels for sending input to running tasks. + /// Each sender allows sending messages to the stdin of a running Claude process. + task_inputs: Arc>>>, + /// Tracks merge state per orchestrator task (for completion gate). + merge_trackers: Arc>>, +} + +impl TaskManager { + /// Create a new task manager. + pub fn new(config: TaskConfig, ws_tx: mpsc::Sender) -> Self { + let max_concurrent = config.max_concurrent_tasks as usize; + let worktree_manager = Arc::new(WorktreeManager::new(config.worktree_base_dir.clone())); + let process_manager = Arc::new( + ProcessManager::with_command(config.claude_command.clone()) + .with_args(config.claude_args.clone()) + .with_pre_args(config.claude_pre_args.clone()) + .with_permissions_enabled(config.enable_permissions) + .with_verbose_disabled(config.disable_verbose) + .with_env(config.env_vars.clone()), + ); + let temp_manager = Arc::new(TempManager::new()); + + Self { + worktree_manager, + process_manager, + temp_manager, + config, + tasks: Arc::new(RwLock::new(HashMap::new())), + ws_tx, + semaphore: Arc::new(Semaphore::new(max_concurrent)), + task_inputs: Arc::new(RwLock::new(HashMap::new())), + merge_trackers: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Handle a command from the server. + pub async fn handle_command(&self, command: DaemonCommand) -> Result<(), DaemonError> { + tracing::info!("Received command from server: {:?}", command); + + match command { + DaemonCommand::SpawnTask { + task_id, + task_name, + plan, + repo_url, + base_branch, + target_branch, + parent_task_id, + depth, + is_orchestrator, + target_repo_path, + completion_action, + continue_from_task_id, + copy_files, + } => { + tracing::info!( + task_id = %task_id, + task_name = %task_name, + repo_url = ?repo_url, + base_branch = ?base_branch, + target_branch = ?target_branch, + parent_task_id = ?parent_task_id, + depth = depth, + is_orchestrator = is_orchestrator, + target_repo_path = ?target_repo_path, + completion_action = ?completion_action, + continue_from_task_id = ?continue_from_task_id, + copy_files = ?copy_files, + plan_len = plan.len(), + "Spawning new task" + ); + self.spawn_task( + task_id, task_name, plan, repo_url, base_branch, target_branch, + parent_task_id, depth, is_orchestrator, + target_repo_path, completion_action, continue_from_task_id, + copy_files + ).await?; + } + DaemonCommand::PauseTask { task_id } => { + tracing::info!(task_id = %task_id, "Pause not supported for subprocess tasks"); + // Subprocesses don't support pause, just log and ignore + } + DaemonCommand::ResumeTask { task_id } => { + tracing::info!(task_id = %task_id, "Resume not supported for subprocess tasks"); + // Subprocesses don't support resume, just log and ignore + } + DaemonCommand::InterruptTask { task_id, graceful: _ } => { + tracing::info!(task_id = %task_id, "Interrupting task"); + self.interrupt_task(task_id).await?; + } + DaemonCommand::SendMessage { task_id, message } => { + tracing::info!(task_id = %task_id, message_len = message.len(), "Sending message to task"); + // Send message to the task's stdin via the input channel + let inputs = self.task_inputs.read().await; + if let Some(sender) = inputs.get(&task_id) { + if let Err(e) = sender.send(message).await { + tracing::warn!(task_id = %task_id, error = %e, "Failed to send message to task input channel"); + } else { + tracing::info!(task_id = %task_id, "Message sent to task successfully"); + } + } else { + tracing::warn!(task_id = %task_id, "No input channel for task (task may not be running)"); + } + } + DaemonCommand::InjectSiblingContext { task_id, .. } => { + tracing::debug!(task_id = %task_id, "Sibling context injection not supported for subprocess tasks"); + } + DaemonCommand::Authenticated { daemon_id } => { + tracing::debug!(daemon_id = %daemon_id, "Authenticated command (handled by WS client)"); + } + DaemonCommand::Error { code, message } => { + tracing::warn!(code = %code, message = %message, "Error command from server"); + } + + // ========================================================================= + // Merge Commands + // ========================================================================= + + DaemonCommand::ListBranches { task_id } => { + tracing::info!(task_id = %task_id, "Listing task branches"); + self.handle_list_branches(task_id).await?; + } + DaemonCommand::MergeStart { task_id, source_branch } => { + tracing::info!(task_id = %task_id, source_branch = %source_branch, "Starting merge"); + self.handle_merge_start(task_id, source_branch).await?; + } + DaemonCommand::MergeStatus { task_id } => { + tracing::info!(task_id = %task_id, "Getting merge status"); + self.handle_merge_status(task_id).await?; + } + DaemonCommand::MergeResolve { task_id, file, strategy } => { + tracing::info!(task_id = %task_id, file = %file, strategy = %strategy, "Resolving conflict"); + self.handle_merge_resolve(task_id, file, strategy).await?; + } + DaemonCommand::MergeCommit { task_id, message } => { + tracing::info!(task_id = %task_id, "Committing merge"); + self.handle_merge_commit(task_id, message).await?; + } + DaemonCommand::MergeAbort { task_id } => { + tracing::info!(task_id = %task_id, "Aborting merge"); + self.handle_merge_abort(task_id).await?; + } + DaemonCommand::MergeSkip { task_id, subtask_id, reason } => { + tracing::info!(task_id = %task_id, subtask_id = %subtask_id, reason = %reason, "Skipping subtask merge"); + self.handle_merge_skip(task_id, subtask_id, reason).await?; + } + DaemonCommand::CheckMergeComplete { task_id } => { + tracing::info!(task_id = %task_id, "Checking merge completion"); + self.handle_check_merge_complete(task_id).await?; + } + + // ========================================================================= + // Completion Action Commands + // ========================================================================= + + DaemonCommand::RetryCompletionAction { + task_id, + task_name, + action, + target_repo_path, + target_branch, + } => { + tracing::info!( + task_id = %task_id, + task_name = %task_name, + action = %action, + target_repo_path = %target_repo_path, + target_branch = ?target_branch, + "Retrying completion action" + ); + self.handle_retry_completion_action(task_id, task_name, action, target_repo_path, target_branch).await?; + } + + DaemonCommand::CloneWorktree { task_id, target_dir } => { + tracing::info!( + task_id = %task_id, + target_dir = %target_dir, + "Cloning worktree to target directory" + ); + self.handle_clone_worktree(task_id, target_dir).await?; + } + + DaemonCommand::CheckTargetExists { task_id, target_dir } => { + tracing::debug!( + task_id = %task_id, + target_dir = %target_dir, + "Checking if target directory exists" + ); + self.handle_check_target_exists(task_id, target_dir).await?; + } + } + Ok(()) + } + + /// Spawn a new task. + #[allow(clippy::too_many_arguments)] + pub async fn spawn_task( + &self, + task_id: Uuid, + task_name: String, + plan: String, + repo_url: Option, + base_branch: Option, + target_branch: Option, + parent_task_id: Option, + depth: i32, + is_orchestrator: bool, + target_repo_path: Option, + completion_action: Option, + continue_from_task_id: Option, + copy_files: Option>, + ) -> TaskResult<()> { + tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, depth = depth, "=== SPAWN_TASK START ==="); + + // Check if task already exists - allow re-spawning if in terminal state + { + let mut tasks = self.tasks.write().await; + if let Some(existing) = tasks.get(&task_id) { + if existing.state.is_terminal() { + // Task exists but is in terminal state (completed, failed, interrupted) + // Remove it so we can re-spawn + tracing::info!(task_id = %task_id, old_state = ?existing.state, "Removing terminated task to allow re-spawn"); + tasks.remove(&task_id); + } else { + // Task is still active, reject + tracing::warn!(task_id = %task_id, state = ?existing.state, "Task already exists and is active, rejecting spawn"); + return Err(TaskError::AlreadyExists(task_id)); + } + } + } + + // Acquire semaphore permit + tracing::info!(task_id = %task_id, "Acquiring concurrency permit..."); + let permit = self + .semaphore + .clone() + .try_acquire_owned() + .map_err(|_| { + tracing::warn!(task_id = %task_id, "Concurrency limit reached, cannot spawn task"); + TaskError::ConcurrencyLimit + })?; + tracing::info!(task_id = %task_id, "Concurrency permit acquired"); + + // Create task entry + tracing::info!(task_id = %task_id, "Creating task entry in state: Initializing"); + let task = ManagedTask { + id: task_id, + state: TaskState::Initializing, + worktree: None, + plan: plan.clone(), + repo_source: repo_url.clone(), + base_branch: base_branch.clone(), + target_branch: target_branch.clone(), + parent_task_id, + depth, + is_orchestrator, + target_repo_path: target_repo_path.clone(), + completion_action: completion_action.clone(), + continue_from_task_id, + copy_files: copy_files.clone(), + created_at: Instant::now(), + started_at: None, + completed_at: None, + error: None, + }; + + self.tasks.write().await.insert(task_id, task); + tracing::info!(task_id = %task_id, "Task entry created and stored"); + + // Notify server of status change + tracing::info!(task_id = %task_id, "Notifying server: pending -> initializing"); + self.send_status_change(task_id, "pending", "initializing").await; + + // Spawn task in background + tracing::info!(task_id = %task_id, "Spawning background task runner"); + let inner = self.clone_inner(); + tokio::spawn(async move { + let _permit = permit; // Hold permit until done + tracing::info!(task_id = %task_id, "Background task runner started"); + + if let Err(e) = inner.run_task( + task_id, task_name, plan, repo_url, base_branch, target_branch, + is_orchestrator, target_repo_path, completion_action, + continue_from_task_id, copy_files + ).await { + tracing::error!(task_id = %task_id, error = %e, "Task execution failed"); + inner.mark_failed(task_id, &e.to_string()).await; + } + tracing::info!(task_id = %task_id, "Background task runner completed"); + }); + + tracing::info!(task_id = %task_id, "=== SPAWN_TASK END (task running in background) ==="); + Ok(()) + } + + /// Clone inner state for spawned tasks. + fn clone_inner(&self) -> TaskManagerInner { + TaskManagerInner { + worktree_manager: self.worktree_manager.clone(), + process_manager: self.process_manager.clone(), + temp_manager: self.temp_manager.clone(), + tasks: self.tasks.clone(), + ws_tx: self.ws_tx.clone(), + task_inputs: self.task_inputs.clone(), + } + } + + /// Interrupt a task. + pub async fn interrupt_task(&self, task_id: Uuid) -> TaskResult<()> { + let mut tasks = self.tasks.write().await; + let task = tasks.get_mut(&task_id).ok_or(TaskError::NotFound(task_id))?; + + if task.state.is_terminal() { + return Ok(()); // Already done + } + + let old_state = task.state; + task.state = TaskState::Interrupted; + task.completed_at = Some(Instant::now()); + + // Notify server + drop(tasks); + self.send_status_change(task_id, old_state.as_str(), "interrupted").await; + + // Note: The process will be killed when the ClaudeProcess is dropped + // Worktrees are kept until explicitly deleted + + Ok(()) + } + + /// Get list of active task IDs. + pub async fn active_task_ids(&self) -> Vec { + self.tasks + .read() + .await + .iter() + .filter(|(_, t)| t.state.is_active()) + .map(|(id, _)| *id) + .collect() + } + + /// Get task state. + pub async fn get_task_state(&self, task_id: Uuid) -> Option { + self.tasks.read().await.get(&task_id).map(|t| t.state) + } + + /// Send status change notification to server. + async fn send_status_change(&self, task_id: Uuid, old_status: &str, new_status: &str) { + let msg = DaemonMessage::task_status_change(task_id, old_status, new_status); + let _ = self.ws_tx.send(msg).await; + } + + // ========================================================================= + // Merge Handler Methods + // ========================================================================= + + /// Get worktree path for a task, or return error if not found. + /// First checks in-memory tasks, then scans the worktrees directory. + async fn get_task_worktree_path(&self, task_id: Uuid) -> Result { + // First try to get from in-memory tasks + { + let tasks = self.tasks.read().await; + if let Some(task) = tasks.get(&task_id) { + if let Some(ref worktree) = task.worktree { + return Ok(worktree.path.clone()); + } + } + } + + // Task not in memory - scan worktrees directory for matching task ID + let short_id = &task_id.to_string()[..8]; + let worktrees_dir = self.worktree_manager.base_dir(); + + if let Ok(mut entries) = tokio::fs::read_dir(worktrees_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(short_id) { + let path = entry.path(); + // Verify it's a valid git directory + if path.join(".git").exists() { + tracing::info!( + task_id = %task_id, + worktree_path = %path.display(), + "Found worktree by scanning directory" + ); + return Ok(path); + } + } + } + } + + Err(DaemonError::Task(TaskError::SetupFailed( + format!("No worktree found for task {}. The worktree may have been cleaned up.", task_id) + ))) + } + + /// Handle ListBranches command. + async fn handle_list_branches(&self, task_id: Uuid) -> Result<(), DaemonError> { + let worktree_path = self.get_task_worktree_path(task_id).await?; + + match self.worktree_manager.list_task_branches(&worktree_path).await { + Ok(branches) => { + let branch_infos: Vec = branches + .into_iter() + .map(|b| BranchInfo { + name: b.name, + task_id: b.task_id, + is_merged: b.is_merged, + last_commit: b.last_commit, + last_commit_message: b.last_commit_message, + }) + .collect(); + + let msg = DaemonMessage::BranchList { + task_id, + branches: branch_infos, + }; + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + tracing::error!(task_id = %task_id, error = %e, "Failed to list branches"); + let msg = DaemonMessage::MergeResult { + task_id, + success: false, + message: e.to_string(), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + } + Ok(()) + } + + /// Handle MergeStart command. + async fn handle_merge_start(&self, task_id: Uuid, source_branch: String) -> Result<(), DaemonError> { + let worktree_path = self.get_task_worktree_path(task_id).await?; + + match self.worktree_manager.merge_branch(&worktree_path, &source_branch).await { + Ok(None) => { + // Merge succeeded without conflicts + let msg = DaemonMessage::MergeResult { + task_id, + success: true, + message: "Merge completed without conflicts".to_string(), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + Ok(Some(conflicts)) => { + // Merge has conflicts + let msg = DaemonMessage::MergeResult { + task_id, + success: false, + message: format!("Merge has {} conflicts", conflicts.len()), + commit_sha: None, + conflicts: Some(conflicts), + }; + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + let msg = DaemonMessage::MergeResult { + task_id, + success: false, + message: e.to_string(), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + } + Ok(()) + } + + /// Handle MergeStatus command. + async fn handle_merge_status(&self, task_id: Uuid) -> Result<(), DaemonError> { + let worktree_path = self.get_task_worktree_path(task_id).await?; + + match self.worktree_manager.get_merge_state(&worktree_path).await { + Ok(state) => { + let msg = DaemonMessage::MergeStatusResponse { + task_id, + in_progress: state.in_progress, + source_branch: if state.in_progress { Some(state.source_branch) } else { None }, + conflicted_files: state.conflicted_files, + }; + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + tracing::error!(task_id = %task_id, error = %e, "Failed to get merge status"); + let msg = DaemonMessage::MergeStatusResponse { + task_id, + in_progress: false, + source_branch: None, + conflicted_files: vec![], + }; + let _ = self.ws_tx.send(msg).await; + } + } + Ok(()) + } + + /// Handle MergeResolve command. + async fn handle_merge_resolve(&self, task_id: Uuid, file: String, strategy: String) -> Result<(), DaemonError> { + let worktree_path = self.get_task_worktree_path(task_id).await?; + + let resolution = match strategy.to_lowercase().as_str() { + "ours" => ConflictResolution::Ours, + "theirs" => ConflictResolution::Theirs, + _ => { + let msg = DaemonMessage::MergeResult { + task_id, + success: false, + message: format!("Invalid strategy '{}', must be 'ours' or 'theirs'", strategy), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + return Ok(()); + } + }; + + match self.worktree_manager.resolve_conflict(&worktree_path, &file, resolution).await { + Ok(()) => { + let msg = DaemonMessage::MergeResult { + task_id, + success: true, + message: format!("Resolved conflict in {}", file), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + let msg = DaemonMessage::MergeResult { + task_id, + success: false, + message: e.to_string(), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + } + Ok(()) + } + + /// Handle MergeCommit command. + async fn handle_merge_commit(&self, task_id: Uuid, message: String) -> Result<(), DaemonError> { + let worktree_path = self.get_task_worktree_path(task_id).await?; + + match self.worktree_manager.commit_merge(&worktree_path, &message).await { + Ok(commit_sha) => { + // Track this merge as completed (extract subtask ID from branch if possible) + // For now, we'll track it when MergeSkip is called or based on branch names + let msg = DaemonMessage::MergeResult { + task_id, + success: true, + message: "Merge committed successfully".to_string(), + commit_sha: Some(commit_sha), + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + let msg = DaemonMessage::MergeResult { + task_id, + success: false, + message: e.to_string(), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + } + Ok(()) + } + + /// Handle MergeAbort command. + async fn handle_merge_abort(&self, task_id: Uuid) -> Result<(), DaemonError> { + let worktree_path = self.get_task_worktree_path(task_id).await?; + + match self.worktree_manager.abort_merge(&worktree_path).await { + Ok(()) => { + let msg = DaemonMessage::MergeResult { + task_id, + success: true, + message: "Merge aborted".to_string(), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + let msg = DaemonMessage::MergeResult { + task_id, + success: false, + message: e.to_string(), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + } + Ok(()) + } + + /// Handle MergeSkip command. + async fn handle_merge_skip(&self, task_id: Uuid, subtask_id: Uuid, reason: String) -> Result<(), DaemonError> { + // Record that this subtask was skipped + { + let mut trackers = self.merge_trackers.write().await; + let tracker = trackers.entry(task_id).or_insert_with(MergeTracker::default); + tracker.skipped_subtasks.insert(subtask_id, reason.clone()); + } + + let msg = DaemonMessage::MergeResult { + task_id, + success: true, + message: format!("Subtask {} skipped: {}", subtask_id, reason), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + + /// Handle CheckMergeComplete command. + async fn handle_check_merge_complete(&self, task_id: Uuid) -> Result<(), DaemonError> { + let worktree_path = self.get_task_worktree_path(task_id).await?; + + // Get all task branches + let branches = match self.worktree_manager.list_task_branches(&worktree_path).await { + Ok(b) => b, + Err(e) => { + let msg = DaemonMessage::MergeCompleteCheck { + task_id, + can_complete: false, + unmerged_branches: vec![format!("Error listing branches: {}", e)], + merged_count: 0, + skipped_count: 0, + }; + let _ = self.ws_tx.send(msg).await; + return Ok(()); + } + }; + + // Get tracker state + let trackers = self.merge_trackers.read().await; + let empty_merged: HashSet = HashSet::new(); + let empty_skipped: HashMap = HashMap::new(); + let tracker = trackers.get(&task_id); + let merged_set = tracker.map(|t| &t.merged_subtasks).unwrap_or(&empty_merged); + let skipped_set = tracker.map(|t| &t.skipped_subtasks).unwrap_or(&empty_skipped); + + let mut merged_count = 0u32; + let mut skipped_count = 0u32; + let mut unmerged_branches = Vec::new(); + + for branch in &branches { + if branch.is_merged { + merged_count += 1; + } else if let Some(subtask_id) = branch.task_id { + if merged_set.contains(&subtask_id) { + merged_count += 1; + } else if skipped_set.contains_key(&subtask_id) { + skipped_count += 1; + } else { + unmerged_branches.push(branch.name.clone()); + } + } else { + // Branch without task ID - check if it's merged + unmerged_branches.push(branch.name.clone()); + } + } + + let can_complete = unmerged_branches.is_empty(); + + let msg = DaemonMessage::MergeCompleteCheck { + task_id, + can_complete, + unmerged_branches, + merged_count, + skipped_count, + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + + /// Mark a subtask as merged in the tracker. + #[allow(dead_code)] + pub async fn mark_subtask_merged(&self, orchestrator_task_id: Uuid, subtask_id: Uuid) { + let mut trackers = self.merge_trackers.write().await; + let tracker = trackers.entry(orchestrator_task_id).or_insert_with(MergeTracker::default); + tracker.merged_subtasks.insert(subtask_id); + } + + // ========================================================================= + // Completion Action Handler Methods + // ========================================================================= + + /// Handle RetryCompletionAction command. + async fn handle_retry_completion_action( + &self, + task_id: Uuid, + task_name: String, + action: String, + target_repo_path: String, + target_branch: Option, + ) -> Result<(), DaemonError> { + // Get the task's worktree path + let worktree_path = self.get_task_worktree_path(task_id).await?; + + // Execute the completion action + let inner = self.clone_inner(); + let result = inner.execute_completion_action( + task_id, + &task_name, + &worktree_path, + &action, + Some(target_repo_path.as_str()), + target_branch.as_deref(), + ).await; + + // Send result back to server + let msg = match result { + Ok(pr_url) => DaemonMessage::CompletionActionResult { + task_id, + success: true, + message: match action.as_str() { + "branch" => format!("Branch pushed to {}", target_repo_path), + "merge" => format!("Merged into {}", target_branch.as_deref().unwrap_or("main")), + "pr" => format!("Pull request created"), + _ => format!("Completion action '{}' executed", action), + }, + pr_url, + }, + Err(e) => DaemonMessage::CompletionActionResult { + task_id, + success: false, + message: e, + pr_url: None, + }, + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + + /// Handle CloneWorktree command. + async fn handle_clone_worktree( + &self, + task_id: Uuid, + target_dir: String, + ) -> Result<(), DaemonError> { + // Get the task's worktree path + let worktree_path = self.get_task_worktree_path(task_id).await?; + + // Expand tilde in target path + let target_path = crate::worktree::expand_tilde(&target_dir); + + // Clone the worktree to target directory + let result = self.worktree_manager.clone_worktree_to_directory( + &worktree_path, + &target_path, + ).await; + + // Send result back to server + let msg = match result { + Ok(message) => DaemonMessage::CloneWorktreeResult { + task_id, + success: true, + message, + target_dir: Some(target_path.to_string_lossy().to_string()), + }, + Err(e) => DaemonMessage::CloneWorktreeResult { + task_id, + success: false, + message: e.to_string(), + target_dir: None, + }, + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + + /// Handle CheckTargetExists command. + async fn handle_check_target_exists( + &self, + task_id: Uuid, + target_dir: String, + ) -> Result<(), DaemonError> { + // Expand tilde in target path + let target_path = crate::worktree::expand_tilde(&target_dir); + + // Check if target exists + let exists = self.worktree_manager.target_directory_exists(&target_path).await; + + // Send result back to server + let msg = DaemonMessage::CheckTargetExistsResult { + task_id, + exists, + target_dir: target_path.to_string_lossy().to_string(), + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } +} + +/// Inner state for spawned tasks (cloneable). +struct TaskManagerInner { + worktree_manager: Arc, + process_manager: Arc, + temp_manager: Arc, + tasks: Arc>>, + ws_tx: mpsc::Sender, + task_inputs: Arc>>>, +} + +impl TaskManagerInner { + /// Run a task to completion. + #[allow(clippy::too_many_arguments)] + async fn run_task( + &self, + task_id: Uuid, + task_name: String, + plan: String, + repo_source: Option, + base_branch: Option, + target_branch: Option, + is_orchestrator: bool, + target_repo_path: Option, + completion_action: Option, + continue_from_task_id: Option, + copy_files: Option>, + ) -> Result<(), DaemonError> { + tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, "=== RUN_TASK START ==="); + + // Determine working directory + let working_dir = if let Some(ref source) = repo_source { + if is_new_repo_request(source) { + // Explicit new repo request: new:// or new://project-name + tracing::info!( + task_id = %task_id, + source = %source, + "Creating new git repository" + ); + + let msg = DaemonMessage::task_output( + task_id, + format!("Initializing new git repository...\n"), + false, + ); + let _ = self.ws_tx.send(msg).await; + + let worktree_info = self.worktree_manager + .init_new_repo(task_id, source) + .await + .map_err(|e| DaemonError::Task(TaskError::SetupFailed(e.to_string())))?; + + tracing::info!( + task_id = %task_id, + path = %worktree_info.path.display(), + "New repository created" + ); + + // Store worktree info + { + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.get_mut(&task_id) { + task.worktree = Some(worktree_info.clone()); + } + } + + let msg = DaemonMessage::task_output( + task_id, + format!("Repository ready at {}\n", worktree_info.path.display()), + false, + ); + let _ = self.ws_tx.send(msg).await; + + worktree_info.path + } else { + // Send progress message + let msg = DaemonMessage::task_output( + task_id, + format!("Setting up worktree from {}...\n", source), + false, + ); + let _ = self.ws_tx.send(msg).await; + + // Ensure source repo exists (clone if URL, verify if path) + let source_repo = self.worktree_manager.ensure_repo(source).await + .map_err(|e| DaemonError::Task(TaskError::SetupFailed(e.to_string())))?; + + // Detect or use provided base branch + let branch = if let Some(ref b) = base_branch { + b.clone() + } else { + self.worktree_manager.detect_default_branch(&source_repo).await + .map_err(|e| DaemonError::Task(TaskError::SetupFailed(e.to_string())))? + }; + + tracing::info!( + task_id = %task_id, + source = %source, + branch = %branch, + continue_from_task_id = ?continue_from_task_id, + "Setting up worktree" + ); + + // Create worktree - either from scratch or copying from another task + let task_name = format!("task-{}", &task_id.to_string()[..8]); + let worktree_info = if let Some(from_task_id) = continue_from_task_id { + // Find the source task's worktree path + let source_worktree = self.find_worktree_for_task(from_task_id).await + .map_err(|e| DaemonError::Task(TaskError::SetupFailed( + format!("Cannot continue from task {}: {}", from_task_id, e) + )))?; + + let msg = DaemonMessage::task_output( + task_id, + format!("Continuing from task {} worktree...\n", &from_task_id.to_string()[..8]), + false, + ); + let _ = self.ws_tx.send(msg).await; + + // Create worktree by copying from source task + self.worktree_manager + .create_worktree_from_task(&source_worktree, task_id, &task_name) + .await + .map_err(|e| DaemonError::Task(TaskError::SetupFailed(e.to_string())))? + } else { + // Create fresh worktree from repo + self.worktree_manager + .create_worktree(&source_repo, task_id, &task_name, &branch) + .await + .map_err(|e| DaemonError::Task(TaskError::SetupFailed(e.to_string())))? + }; + + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_info.path.display(), + branch = %worktree_info.branch, + continued_from = ?continue_from_task_id, + "Worktree created" + ); + + // Store worktree info + { + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.get_mut(&task_id) { + task.worktree = Some(worktree_info.clone()); + } + } + + let msg = DaemonMessage::task_output( + task_id, + format!("Worktree ready at {}\n", worktree_info.path.display()), + false, + ); + let _ = self.ws_tx.send(msg).await; + + worktree_info.path + } + } else { + // No repo specified - use managed temp directory in ~/.makima/temp/ + tracing::info!(task_id = %task_id, "Creating managed temp directory (no repo)"); + + let msg = DaemonMessage::task_output( + task_id, + "Creating temporary working directory...\n".to_string(), + false, + ); + let _ = self.ws_tx.send(msg).await; + + let temp_dir = self.temp_manager.create_task_dir(task_id).await?; + + let msg = DaemonMessage::task_output( + task_id, + format!("Working directory ready at {}\n", temp_dir.display()), + false, + ); + let _ = self.ws_tx.send(msg).await; + + temp_dir + }; + + // Copy files from parent task's worktree if specified + if let Some(ref files) = copy_files { + if !files.is_empty() { + // Get the parent task ID to find its worktree + let parent_task_id = { + let tasks = self.tasks.read().await; + tasks.get(&task_id).and_then(|t| t.parent_task_id) + }; + + if let Some(parent_id) = parent_task_id { + match self.find_worktree_for_task(parent_id).await { + Ok(parent_worktree) => { + let msg = DaemonMessage::task_output( + task_id, + format!("Copying {} files from orchestrator...\n", files.len()), + false, + ); + let _ = self.ws_tx.send(msg).await; + + for file_path in files { + let source = parent_worktree.join(file_path); + let dest = working_dir.join(file_path); + + // Create parent directories if needed + if let Some(parent) = dest.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + tracing::warn!( + task_id = %task_id, + file = %file_path, + error = %e, + "Failed to create parent directory for file" + ); + continue; + } + } + + // Copy the file + match tokio::fs::copy(&source, &dest).await { + Ok(_) => { + tracing::info!( + task_id = %task_id, + source = %source.display(), + dest = %dest.display(), + "Copied file from orchestrator" + ); + } + Err(e) => { + tracing::warn!( + task_id = %task_id, + source = %source.display(), + dest = %dest.display(), + error = %e, + "Failed to copy file from orchestrator" + ); + // Notify but don't fail - the file might be optional + let msg = DaemonMessage::task_output( + task_id, + format!("Warning: Could not copy {}: {}\n", file_path, e), + false, + ); + let _ = self.ws_tx.send(msg).await; + } + } + } + + let msg = DaemonMessage::task_output( + task_id, + "Files copied from orchestrator.\n".to_string(), + false, + ); + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + tracing::warn!( + task_id = %task_id, + parent_id = %parent_id, + error = %e, + "Could not find parent task worktree for file copying" + ); + } + } + } else { + tracing::warn!( + task_id = %task_id, + "copy_files specified but no parent_task_id" + ); + } + } + } + + // Update state to Starting + tracing::info!(task_id = %task_id, "Updating state: Initializing -> Starting"); + self.update_state(task_id, TaskState::Starting).await; + self.send_status_change(task_id, "initializing", "starting").await; + + // Check Claude is available + match self.process_manager.check_claude_available().await { + Ok(version) => { + tracing::info!(task_id = %task_id, version = %version, "Claude Code available"); + let msg = DaemonMessage::task_output( + task_id, + format!("Claude Code {} ready\n", version), + false, + ); + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + let err_msg = format!("Claude Code not available: {}", e); + tracing::error!(task_id = %task_id, error = %err_msg); + return Err(DaemonError::Task(TaskError::SetupFailed(err_msg))); + } + } + + // Set up orchestrator mode if needed + let (extra_env, full_plan) = if is_orchestrator { + tracing::info!(task_id = %task_id, working_dir = %working_dir.display(), "Setting up orchestrator mode"); + + let msg = DaemonMessage::task_output( + task_id, + "Setting up orchestrator environment...\n".to_string(), + false, + ); + let _ = self.ws_tx.send(msg).await; + + // Generate tool key for API access + let tool_key = generate_tool_key(); + tracing::info!(task_id = %task_id, tool_key_len = tool_key.len(), "Generated tool key for orchestrator"); + + // Register tool key with server + let register_msg = DaemonMessage::register_tool_key(task_id, tool_key.clone()); + if self.ws_tx.send(register_msg).await.is_err() { + tracing::warn!(task_id = %task_id, "Failed to register tool key"); + } else { + tracing::info!(task_id = %task_id, "Tool key registration message sent to server"); + } + + // Create .makima directory and helper script + let makima_dir = working_dir.join(".makima"); + if let Err(e) = tokio::fs::create_dir_all(&makima_dir).await { + tracing::warn!(task_id = %task_id, makima_dir = %makima_dir.display(), "Failed to create .makima directory: {}", e); + } else { + tracing::info!(task_id = %task_id, makima_dir = %makima_dir.display(), "Created .makima directory"); + } + + let script_path = makima_dir.join("orchestrate.sh"); + if let Err(e) = tokio::fs::write(&script_path, ORCHESTRATE_SCRIPT).await { + tracing::warn!(task_id = %task_id, script_path = %script_path.display(), "Failed to write orchestrate.sh: {}", e); + } else { + tracing::info!(task_id = %task_id, script_path = %script_path.display(), script_size = ORCHESTRATE_SCRIPT.len(), "Wrote orchestrate.sh"); + // Make script executable + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(e) = std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)) { + tracing::warn!(task_id = %task_id, "Failed to set script permissions: {}", e); + } else { + tracing::info!(task_id = %task_id, "Set orchestrate.sh executable (0o755)"); + } + } + } + + // Set up environment variables + let mut env = HashMap::new(); + // TODO: Make API URL configurable + env.insert("MAKIMA_API_URL".to_string(), "http://localhost:8080".to_string()); + env.insert("MAKIMA_API_KEY".to_string(), tool_key.clone()); + env.insert("MAKIMA_TASK_ID".to_string(), task_id.to_string()); + + tracing::info!( + task_id = %task_id, + api_url = "http://localhost:8080", + tool_key_preview = &tool_key[..8.min(tool_key.len())], + "Set orchestrator environment variables" + ); + + // Prepend orchestrator instructions to the plan + let orchestrator_plan = format!( + "{}\n\n---\n\nYour task:\n{}", + ORCHESTRATOR_SYSTEM_PROMPT, + plan + ); + + let msg = DaemonMessage::task_output( + task_id, + format!("Orchestrator environment ready (script at {})\n", script_path.display()), + false, + ); + let _ = self.ws_tx.send(msg).await; + + (Some(env), orchestrator_plan) + } else { + tracing::info!(task_id = %task_id, "Running as regular subtask (not orchestrator)"); + // Prepend subtask instructions to ensure worktree isolation + let subtask_plan = format!( + "{}\nYour task:\n{}", + SUBTASK_SYSTEM_PROMPT, + plan + ); + (None, subtask_plan) + }; + + // Spawn Claude process + let plan_bytes = full_plan.len(); + let plan_chars = full_plan.chars().count(); + // Rough token estimate: ~4 chars per token for English + let estimated_tokens = plan_chars / 4; + + tracing::info!( + task_id = %task_id, + working_dir = %working_dir.display(), + is_orchestrator = is_orchestrator, + plan_bytes = plan_bytes, + plan_chars = plan_chars, + estimated_tokens = estimated_tokens, + "Spawning Claude process" + ); + + // Warn if plan is very large (Claude's context is typically 100k-200k tokens) + if estimated_tokens > 50_000 { + tracing::warn!(task_id = %task_id, estimated_tokens = estimated_tokens, "Plan is very large - may hit context limits!"); + let msg = DaemonMessage::task_output( + task_id, + format!("Warning: Plan is very large (~{} tokens). This may cause issues.\n", estimated_tokens), + false, + ); + let _ = self.ws_tx.send(msg).await; + } + + let msg = DaemonMessage::task_output( + task_id, + if is_orchestrator { + format!("Starting Claude Code (orchestrator mode, ~{} tokens)...\n", estimated_tokens) + } else { + format!("Starting Claude Code (~{} tokens)...\n", estimated_tokens) + }, + false, + ); + let _ = self.ws_tx.send(msg).await; + + tracing::debug!(task_id = %task_id, "Calling process_manager.spawn()..."); + let mut process = self.process_manager + .spawn(&working_dir, &full_plan, extra_env) + .await + .map_err(|e| { + tracing::error!(task_id = %task_id, error = %e, "Failed to spawn Claude process"); + DaemonError::Task(TaskError::SetupFailed(e.to_string())) + })?; + tracing::info!(task_id = %task_id, "Claude process spawned successfully"); + + // Set up input channel for this task so we can send messages to its stdin + tracing::debug!(task_id = %task_id, "Setting up input channel..."); + let (input_tx, mut input_rx) = mpsc::channel::(100); + tracing::debug!(task_id = %task_id, "Acquiring task_inputs write lock..."); + self.task_inputs.write().await.insert(task_id, input_tx); + tracing::debug!(task_id = %task_id, "Input channel registered"); + + // Get stdin handle for input forwarding and completion signaling + let stdin_handle = process.stdin_handle(); + let stdin_handle_for_completion = stdin_handle.clone(); + + tracing::info!(task_id = %task_id, "Setting up stdin forwarder for task input (JSON protocol)"); + tokio::spawn(async move { + tracing::info!(task_id = %task_id, "Stdin forwarder task started, waiting for messages..."); + while let Some(msg) = input_rx.recv().await { + tracing::info!(task_id = %task_id, msg_len = msg.len(), msg_preview = %if msg.len() > 50 { &msg[..50] } else { &msg }, "Received message from input channel"); + + // Format as JSON user message for stream-json input protocol + let json_msg = ClaudeInputMessage::user(&msg); + let json_line = match json_msg.to_json_line() { + Ok(line) => line, + Err(e) => { + tracing::error!(task_id = %task_id, error = %e, "Failed to serialize input message"); + continue; + } + }; + + tracing::debug!(task_id = %task_id, json_line = %json_line.trim(), "Formatted JSON line for stdin"); + + let mut stdin_guard = stdin_handle.lock().await; + if let Some(ref mut stdin) = *stdin_guard { + tracing::debug!(task_id = %task_id, "Acquired stdin lock, writing..."); + if stdin.write_all(json_line.as_bytes()).await.is_err() { + tracing::warn!(task_id = %task_id, "Failed to write to stdin, breaking"); + break; + } + if stdin.flush().await.is_err() { + tracing::warn!(task_id = %task_id, "Failed to flush stdin, breaking"); + break; + } + tracing::info!(task_id = %task_id, json_len = json_line.len(), "Successfully wrote user message to Claude stdin"); + } else { + tracing::warn!(task_id = %task_id, "Stdin is None (already closed), cannot send message"); + break; + } + } + tracing::info!(task_id = %task_id, "Stdin forwarder task ended (channel closed or stdin unavailable)"); + }); + + // Update state to Running + { + tracing::debug!(task_id = %task_id, "Acquiring tasks write lock for Running state update"); + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.get_mut(&task_id) { + task.state = TaskState::Running; + task.started_at = Some(Instant::now()); + } + tracing::debug!(task_id = %task_id, "Released tasks write lock"); + } + tracing::info!(task_id = %task_id, "Updating state: Starting -> Running"); + self.send_status_change(task_id, "starting", "running").await; + tracing::debug!(task_id = %task_id, "Sent status change notification"); + + // Stream output with startup timeout check + tracing::info!(task_id = %task_id, "Starting output stream - waiting for Claude output..."); + tracing::debug!(task_id = %task_id, "Output will be forwarded via WebSocket to server"); + let ws_tx = self.ws_tx.clone(); + + let mut output_count = 0u64; + let mut output_bytes = 0usize; + let startup_timeout = tokio::time::Duration::from_secs(30); + let mut startup_check = tokio::time::interval(tokio::time::Duration::from_secs(5)); + startup_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let startup_deadline = tokio::time::Instant::now() + startup_timeout; + + loop { + tokio::select! { + maybe_line = process.next_output() => { + match maybe_line { + Some(line) => { + output_count += 1; + output_bytes += line.content.len(); + + if output_count == 1 { + tracing::info!(task_id = %task_id, "Received first output line from Claude"); + } + if output_count % 100 == 0 { + tracing::debug!(task_id = %task_id, output_count = output_count, output_bytes = output_bytes, "Output progress"); + } + + // Log output details for debugging + tracing::trace!( + task_id = %task_id, + line_num = output_count, + content_len = line.content.len(), + is_stdout = line.is_stdout, + json_type = ?line.json_type, + "Forwarding output to WebSocket" + ); + + // Check if this is a "result" message indicating task completion + // With --input-format=stream-json, Claude waits for more input after completion + // We close stdin to signal EOF and let the process exit + if line.json_type.as_deref() == Some("result") { + tracing::info!(task_id = %task_id, "Received result message, closing stdin to signal completion"); + let mut stdin_guard = stdin_handle_for_completion.lock().await; + if let Some(mut stdin) = stdin_guard.take() { + let _ = stdin.shutdown().await; + } + } + + let msg = DaemonMessage::task_output(task_id, line.content, false); + if ws_tx.send(msg).await.is_err() { + tracing::warn!(task_id = %task_id, "Failed to send output, channel closed"); + break; + } + } + None => { + tracing::info!(task_id = %task_id, output_count = output_count, output_bytes = output_bytes, "Output stream ended"); + break; + } + } + } + _ = startup_check.tick(), if output_count == 0 => { + // Check if process is still alive + match process.try_wait() { + Ok(Some(exit_code)) => { + tracing::error!(task_id = %task_id, exit_code = exit_code, "Claude process exited before producing output!"); + let msg = DaemonMessage::task_output( + task_id, + format!("Error: Claude process exited unexpectedly with code {}\n", exit_code), + false, + ); + let _ = ws_tx.send(msg).await; + break; + } + Ok(None) => { + // Still running but no output + if tokio::time::Instant::now() > startup_deadline { + tracing::warn!(task_id = %task_id, "Claude process not producing output after 30s - may be stuck"); + let msg = DaemonMessage::task_output( + task_id, + "Warning: Claude Code is taking longer than expected to start. It may be waiting for authentication or network access.\n".to_string(), + false, + ); + let _ = ws_tx.send(msg).await; + } else { + tracing::debug!(task_id = %task_id, "Claude process still running, waiting for output..."); + } + } + Err(e) => { + tracing::error!(task_id = %task_id, error = %e, "Failed to check Claude process status"); + } + } + } + } + } + + // Wait for process to exit + let exit_code = process.wait().await.unwrap_or(-1); + + // Clean up input channel for this task + self.task_inputs.write().await.remove(&task_id); + tracing::debug!(task_id = %task_id, "Removed task input channel"); + + // Update state based on exit code + let success = exit_code == 0; + let new_state = if success { + TaskState::Completed + } else { + TaskState::Failed + }; + + tracing::info!( + task_id = %task_id, + exit_code = exit_code, + success = success, + new_state = ?new_state, + "Claude process exited, updating task state" + ); + + { + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.get_mut(&task_id) { + task.state = new_state; + task.completed_at = Some(Instant::now()); + if !success { + task.error = Some(format!("Process exited with code {}", exit_code)); + } + } + } + + // Execute completion action if task succeeded + let completion_result = if success { + if let Some(ref action) = completion_action { + if action != "none" { + self.execute_completion_action( + task_id, + &task_name, + &working_dir, + action, + target_repo_path.as_deref(), + target_branch.as_deref(), + ).await + } else { + Ok(None) + } + } else { + Ok(None) + } + } else { + Ok(None) + }; + + // Log completion action result + match &completion_result { + Ok(Some(pr_url)) => { + tracing::info!(task_id = %task_id, pr_url = %pr_url, "Completion action created PR"); + } + Ok(None) => {} + Err(e) => { + tracing::warn!(task_id = %task_id, error = %e, "Completion action failed (task still marked as done)"); + } + } + + // Notify server + let error = if success { + None + } else { + Some(format!("Exit code: {}", exit_code)) + }; + tracing::info!(task_id = %task_id, success = success, "Notifying server of task completion"); + let msg = DaemonMessage::task_complete(task_id, success, error); + let _ = self.ws_tx.send(msg).await; + + // Note: Worktrees are kept until explicitly deleted (per user preference) + // This allows inspection, PR creation, etc. + + tracing::info!(task_id = %task_id, "=== RUN_TASK END ==="); + Ok(()) + } + + /// Execute the completion action for a task. + async fn execute_completion_action( + &self, + task_id: Uuid, + task_name: &str, + worktree_path: &std::path::Path, + action: &str, + target_repo_path: Option<&str>, + target_branch: Option<&str>, + ) -> Result, String> { + let target_repo = match target_repo_path { + Some(path) => crate::worktree::expand_tilde(path), + None => { + tracing::warn!(task_id = %task_id, "No target_repo_path configured, skipping completion action"); + return Ok(None); + } + }; + + if !target_repo.exists() { + return Err(format!("Target repo not found: {} (expanded from {:?})", target_repo.display(), target_repo_path)); + } + + // Get the branch name: makima/{task-name-with-dashes}-{short-id} + let branch_name = format!( + "makima/{}-{}", + crate::worktree::sanitize_name(task_name), + crate::worktree::short_uuid(task_id) + ); + + // Determine target branch - use provided value or detect default branch of target repo + let target_branch = match target_branch { + Some(branch) => branch.to_string(), + None => { + // Detect default branch (main, master, develop, etc.) + self.worktree_manager + .detect_default_branch(&target_repo) + .await + .unwrap_or_else(|_| "master".to_string()) + } + }; + + let msg = DaemonMessage::task_output( + task_id, + format!("Executing completion action: {}...\n", action), + false, + ); + let _ = self.ws_tx.send(msg).await; + + match action { + "branch" => { + // Just push the branch to target repo + self.worktree_manager + .push_to_target_repo(worktree_path, &target_repo, &branch_name, task_name) + .await + .map_err(|e| e.to_string())?; + + let msg = DaemonMessage::task_output( + task_id, + format!("Branch '{}' pushed to {}\n", branch_name, target_repo.display()), + false, + ); + let _ = self.ws_tx.send(msg).await; + Ok(None) + } + "merge" => { + // Push and merge into target branch + let commit_sha = self.worktree_manager + .merge_to_target(worktree_path, &target_repo, &branch_name, &target_branch, task_name) + .await + .map_err(|e| e.to_string())?; + + let msg = DaemonMessage::task_output( + task_id, + format!("Branch merged into {} (commit: {})\n", target_branch, commit_sha), + false, + ); + let _ = self.ws_tx.send(msg).await; + Ok(None) + } + "pr" => { + // Push and create PR + let title = task_name.to_string(); + let body = format!( + "Automated PR from makima task.\n\nTask ID: `{}`", + task_id + ); + let pr_url = self.worktree_manager + .create_pull_request( + worktree_path, + &target_repo, + &branch_name, + &target_branch, + &title, + &body, + ) + .await + .map_err(|e| e.to_string())?; + + let msg = DaemonMessage::task_output( + task_id, + format!("Pull request created: {}\n", pr_url), + false, + ); + let _ = self.ws_tx.send(msg).await; + Ok(Some(pr_url)) + } + _ => { + tracing::warn!(task_id = %task_id, action = %action, "Unknown completion action"); + Ok(None) + } + } + } + + /// Find worktree path for a task ID. + /// First checks in-memory tasks, then scans the worktrees directory. + async fn find_worktree_for_task(&self, task_id: Uuid) -> Result { + // First try to get from in-memory tasks + { + let tasks = self.tasks.read().await; + if let Some(task) = tasks.get(&task_id) { + if let Some(ref worktree) = task.worktree { + return Ok(worktree.path.clone()); + } + } + } + + // Task not in memory - scan worktrees directory for matching task ID + let short_id = &task_id.to_string()[..8]; + let worktrees_dir = self.worktree_manager.base_dir(); + + if let Ok(mut entries) = tokio::fs::read_dir(worktrees_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(short_id) { + let path = entry.path(); + // Verify it's a valid git directory + if path.join(".git").exists() { + tracing::info!( + task_id = %task_id, + worktree_path = %path.display(), + "Found worktree by scanning directory" + ); + return Ok(path); + } + } + } + } + + Err(format!( + "No worktree found for task {}. The worktree may have been cleaned up.", + task_id + )) + } + + async fn update_state(&self, task_id: Uuid, state: TaskState) { + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.get_mut(&task_id) { + task.state = state; + } + } + + async fn send_status_change(&self, task_id: Uuid, old_status: &str, new_status: &str) { + let msg = DaemonMessage::task_status_change(task_id, old_status, new_status); + let _ = self.ws_tx.send(msg).await; + } + + /// Mark task as failed. + async fn mark_failed(&self, task_id: Uuid, error: &str) { + { + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.get_mut(&task_id) { + task.state = TaskState::Failed; + task.error = Some(error.to_string()); + task.completed_at = Some(Instant::now()); + } + } + + // Notify server + let msg = DaemonMessage::task_complete(task_id, false, Some(error.to_string())); + let _ = self.ws_tx.send(msg).await; + } +} + +impl Clone for TaskManagerInner { + fn clone(&self) -> Self { + Self { + worktree_manager: self.worktree_manager.clone(), + process_manager: self.process_manager.clone(), + temp_manager: self.temp_manager.clone(), + tasks: self.tasks.clone(), + ws_tx: self.ws_tx.clone(), + task_inputs: self.task_inputs.clone(), + } + } +} diff --git a/makima/daemon/src/task/mod.rs b/makima/daemon/src/task/mod.rs new file mode 100644 index 0000000..29c261e --- /dev/null +++ b/makima/daemon/src/task/mod.rs @@ -0,0 +1,7 @@ +//! Task management and execution. + +pub mod manager; +pub mod state; + +pub use manager::{ManagedTask, TaskConfig, TaskManager}; +pub use state::TaskState; diff --git a/makima/daemon/src/task/state.rs b/makima/daemon/src/task/state.rs new file mode 100644 index 0000000..fe73de1 --- /dev/null +++ b/makima/daemon/src/task/state.rs @@ -0,0 +1,161 @@ +//! Task state machine. + +use std::fmt; + +/// Task execution state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TaskState { + /// Task received, preparing overlay. + Initializing, + /// Overlay ready, starting container. + Starting, + /// Container running. + Running, + /// Container paused. + Paused, + /// Waiting for sibling or resource. + Blocked, + /// Task completed successfully. + Completed, + /// Task failed with error. + Failed, + /// Task interrupted by user. + Interrupted, +} + +impl TaskState { + /// Check if a state transition is valid. + pub fn can_transition_to(&self, target: TaskState) -> bool { + use TaskState::*; + + matches!( + (self, target), + // From Initializing + (Initializing, Starting) + | (Initializing, Failed) + | (Initializing, Interrupted) + // From Starting + | (Starting, Running) + | (Starting, Failed) + | (Starting, Interrupted) + // From Running + | (Running, Paused) + | (Running, Blocked) + | (Running, Completed) + | (Running, Failed) + | (Running, Interrupted) + // From Paused + | (Paused, Running) + | (Paused, Interrupted) + | (Paused, Failed) + // From Blocked + | (Blocked, Running) + | (Blocked, Failed) + | (Blocked, Interrupted) + ) + } + + /// Check if this state is terminal (no more transitions possible). + pub fn is_terminal(&self) -> bool { + matches!( + self, + TaskState::Completed | TaskState::Failed | TaskState::Interrupted + ) + } + + /// Check if the task is currently active (running or paused). + pub fn is_active(&self) -> bool { + matches!( + self, + TaskState::Initializing + | TaskState::Starting + | TaskState::Running + | TaskState::Paused + | TaskState::Blocked + ) + } + + /// Check if the task is running. + pub fn is_running(&self) -> bool { + matches!(self, TaskState::Running) + } + + /// Convert to string for protocol messages. + pub fn as_str(&self) -> &'static str { + match self { + TaskState::Initializing => "initializing", + TaskState::Starting => "starting", + TaskState::Running => "running", + TaskState::Paused => "paused", + TaskState::Blocked => "blocked", + TaskState::Completed => "done", + TaskState::Failed => "failed", + TaskState::Interrupted => "interrupted", + } + } + + /// Parse from string. + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "initializing" => Some(TaskState::Initializing), + "starting" => Some(TaskState::Starting), + "running" => Some(TaskState::Running), + "paused" => Some(TaskState::Paused), + "blocked" => Some(TaskState::Blocked), + "done" | "completed" => Some(TaskState::Completed), + "failed" => Some(TaskState::Failed), + "interrupted" => Some(TaskState::Interrupted), + _ => None, + } + } +} + +impl fmt::Display for TaskState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl Default for TaskState { + fn default() -> Self { + TaskState::Initializing + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_transitions() { + use TaskState::*; + + // Valid transitions + assert!(Initializing.can_transition_to(Starting)); + assert!(Starting.can_transition_to(Running)); + assert!(Running.can_transition_to(Completed)); + assert!(Running.can_transition_to(Paused)); + assert!(Paused.can_transition_to(Running)); + + // Invalid transitions + assert!(!Completed.can_transition_to(Running)); + assert!(!Failed.can_transition_to(Running)); + assert!(!Running.can_transition_to(Initializing)); + } + + #[test] + fn test_terminal_states() { + assert!(TaskState::Completed.is_terminal()); + assert!(TaskState::Failed.is_terminal()); + assert!(TaskState::Interrupted.is_terminal()); + assert!(!TaskState::Running.is_terminal()); + assert!(!TaskState::Paused.is_terminal()); + } + + #[test] + fn test_parse() { + assert_eq!(TaskState::from_str("running"), Some(TaskState::Running)); + assert_eq!(TaskState::from_str("done"), Some(TaskState::Completed)); + assert_eq!(TaskState::from_str("invalid"), None); + } +} diff --git a/makima/daemon/src/temp.rs b/makima/daemon/src/temp.rs new file mode 100644 index 0000000..015b21b --- /dev/null +++ b/makima/daemon/src/temp.rs @@ -0,0 +1,224 @@ +//! Managed temporary directory for tasks without repositories. +//! +//! Tasks that don't have a repository URL and aren't subtasks (which inherit +//! from parent) use a managed temp directory in ~/.makima/temp/. The directory +//! is automatically cleaned up when it exceeds a size limit. + +use std::path::PathBuf; + +use tokio::fs; +use uuid::Uuid; + +/// Maximum size of the temp directory before cleanup (5GB). +const MAX_TEMP_SIZE_BYTES: u64 = 5 * 1024 * 1024 * 1024; + +/// Manages temporary directories for tasks without repositories. +pub struct TempManager { + /// Base directory for temp task directories (~/.makima/temp/). + temp_dir: PathBuf, +} + +impl TempManager { + /// Create a new TempManager. + pub fn new() -> Self { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + Self { + temp_dir: home.join(".makima").join("temp"), + } + } + + /// Create a new TempManager with a custom base directory. + #[allow(dead_code)] + pub fn with_base_dir(base_dir: PathBuf) -> Self { + Self { temp_dir: base_dir } + } + + /// Get the base temp directory path. + pub fn temp_dir(&self) -> &PathBuf { + &self.temp_dir + } + + /// Create a temp directory for a task. + /// + /// This creates a directory at ~/.makima/temp/task-{id}/ and triggers + /// cleanup if the total size exceeds the limit. + pub async fn create_task_dir(&self, task_id: Uuid) -> Result { + // Ensure base directory exists + fs::create_dir_all(&self.temp_dir).await?; + + // Check size and cleanup if needed + if let Err(e) = self.cleanup_if_needed().await { + tracing::warn!("Temp directory cleanup failed: {}", e); + // Continue anyway, cleanup is best-effort + } + + // Create task-specific directory + let task_dir = self.temp_dir.join(format!("task-{}", task_id)); + fs::create_dir_all(&task_dir).await?; + + tracing::info!( + task_id = %task_id, + path = %task_dir.display(), + "Created temp directory for task" + ); + + Ok(task_dir) + } + + /// Calculate total size of temp directory recursively. + async fn get_total_size(&self) -> Result { + if !self.temp_dir.exists() { + return Ok(0); + } + + let mut total = 0u64; + let mut stack = vec![self.temp_dir.clone()]; + + while let Some(dir) = stack.pop() { + let mut entries = match fs::read_dir(&dir).await { + Ok(e) => e, + Err(_) => continue, + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let metadata = match entry.metadata().await { + Ok(m) => m, + Err(_) => continue, + }; + + if metadata.is_dir() { + stack.push(entry.path()); + } else { + total += metadata.len(); + } + } + } + + Ok(total) + } + + /// Remove oldest directories if total size exceeds limit. + async fn cleanup_if_needed(&self) -> Result<(), std::io::Error> { + let size = self.get_total_size().await?; + if size <= MAX_TEMP_SIZE_BYTES { + return Ok(()); + } + + tracing::info!( + current_size_mb = size / 1024 / 1024, + limit_mb = MAX_TEMP_SIZE_BYTES / 1024 / 1024, + "Temp directory exceeds size limit, starting cleanup" + ); + + // Get all task dirs with modification times + let mut dirs: Vec<(PathBuf, std::time::SystemTime, u64)> = vec![]; + let mut entries = fs::read_dir(&self.temp_dir).await?; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let metadata = match entry.metadata().await { + Ok(m) => m, + Err(_) => continue, + }; + + let modified = metadata.modified().unwrap_or(std::time::UNIX_EPOCH); + let dir_size = self.get_dir_size(&path).await.unwrap_or(0); + dirs.push((path, modified, dir_size)); + } + + // Sort by oldest first + dirs.sort_by(|a, b| a.1.cmp(&b.1)); + + // Remove oldest until under limit + let mut current_size = size; + for (path, _, dir_size) in dirs { + if current_size <= MAX_TEMP_SIZE_BYTES { + break; + } + + tracing::info!( + path = %path.display(), + size_mb = dir_size / 1024 / 1024, + "Removing old temp directory" + ); + + if let Err(e) = fs::remove_dir_all(&path).await { + tracing::warn!(path = %path.display(), error = %e, "Failed to remove temp directory"); + continue; + } + + current_size = current_size.saturating_sub(dir_size); + } + + tracing::info!( + new_size_mb = current_size / 1024 / 1024, + "Temp directory cleanup complete" + ); + + Ok(()) + } + + /// Calculate size of a directory recursively. + async fn get_dir_size(&self, path: &PathBuf) -> Result { + let mut total = 0u64; + let mut stack = vec![path.clone()]; + + while let Some(dir) = stack.pop() { + let mut entries = match fs::read_dir(&dir).await { + Ok(e) => e, + Err(_) => continue, + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let metadata = match entry.metadata().await { + Ok(m) => m, + Err(_) => continue, + }; + + if metadata.is_dir() { + stack.push(entry.path()); + } else { + total += metadata.len(); + } + } + } + + Ok(total) + } + + /// Remove a specific task's temp directory. + #[allow(dead_code)] + pub async fn remove_task_dir(&self, task_id: Uuid) -> Result<(), std::io::Error> { + let task_dir = self.temp_dir.join(format!("task-{}", task_id)); + if task_dir.exists() { + fs::remove_dir_all(&task_dir).await?; + tracing::info!( + task_id = %task_id, + path = %task_dir.display(), + "Removed temp directory for task" + ); + } + Ok(()) + } +} + +impl Default for TempManager { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_temp_manager_default_dir() { + let manager = TempManager::new(); + assert!(manager.temp_dir().ends_with(".makima/temp")); + } +} diff --git a/makima/daemon/src/worktree/manager.rs b/makima/daemon/src/worktree/manager.rs new file mode 100644 index 0000000..266b970 --- /dev/null +++ b/makima/daemon/src/worktree/manager.rs @@ -0,0 +1,1623 @@ +//! Worktree manager implementation. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use tokio::process::Command; +use tokio::sync::Mutex; +use uuid::Uuid; + +/// Errors that can occur during worktree operations. +#[derive(Debug, thiserror::Error)] +pub enum WorktreeError { + #[error("Git command failed: {0}")] + GitCommand(String), + + #[error("Repository not found: {0}")] + RepoNotFound(String), + + #[error("Failed to create directory: {0}")] + CreateDir(#[from] std::io::Error), + + #[error("Invalid repository path: {0}")] + InvalidPath(String), + + #[error("Worktree already exists: {0}")] + AlreadyExists(String), + + #[error("Clone failed: {0}")] + CloneFailed(String), + + #[error("Merge in progress")] + MergeInProgress, + + #[error("No merge in progress")] + NoMergeInProgress, + + #[error("Merge has conflicts: {0}")] + MergeConflicts(String), +} + +/// Strategy for resolving a merge conflict. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConflictResolution { + /// Use our version (the branch being merged into). + Ours, + /// Use their version (the branch being merged). + Theirs, +} + +/// State of an in-progress merge. +#[derive(Debug, Clone)] +pub struct MergeState { + /// The branch being merged. + pub source_branch: String, + /// Files with unresolved conflicts. + pub conflicted_files: Vec, + /// Whether a merge is currently in progress. + pub in_progress: bool, +} + +/// Information about a task branch. +#[derive(Debug, Clone)] +pub struct TaskBranchInfo { + /// Full branch name. + pub name: String, + /// Task ID extracted from branch name (if parseable). + pub task_id: Option, + /// Whether this branch has been merged into the current branch. + pub is_merged: bool, + /// Short SHA of the last commit. + pub last_commit: String, + /// Subject line of the last commit. + pub last_commit_message: String, +} + +/// Information about a created worktree. +#[derive(Debug, Clone)] +pub struct WorktreeInfo { + /// Path to the worktree directory. + pub path: PathBuf, + /// Git branch name for this worktree. + pub branch: String, + /// Source repository path. + pub source_repo: PathBuf, +} + +/// Manages git worktrees for task isolation. +pub struct WorktreeManager { + /// Base directory for all worktrees (~/.makima/worktrees). + base_dir: PathBuf, + /// Base directory for cloned repos (~/.makima/repos). + repos_dir: PathBuf, + /// Branch prefix for task branches. + branch_prefix: String, +} + +/// Per-worktree locks to prevent concurrent creation issues. +static WORKTREE_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +impl WorktreeManager { + /// Create a new WorktreeManager with the given base directory. + pub fn new(base_dir: PathBuf) -> Self { + let repos_dir = base_dir.parent() + .map(|p| p.join("repos")) + .unwrap_or_else(|| base_dir.join("repos")); + + Self { + base_dir, + repos_dir, + branch_prefix: "makima/task-".to_string(), + } + } + + /// Get the default worktree base directory (~/.makima/worktrees). + pub fn default_base_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".makima") + .join("worktrees") + } + + /// Get the base directory for worktrees. + pub fn base_dir(&self) -> &Path { + &self.base_dir + } + + /// Detect the default branch of a repository. + /// Tries to find HEAD's target, falling back to common branch names. + pub async fn detect_default_branch(&self, repo_path: &Path) -> Result { + // Try to get the branch that HEAD points to + let output = Command::new("git") + .args(["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]) + .current_dir(repo_path) + .output() + .await?; + + if output.status.success() { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + // Remove "origin/" prefix if present + let branch = branch.strip_prefix("origin/").unwrap_or(&branch).to_string(); + if !branch.is_empty() { + return Ok(branch); + } + } + + // Try common branch names + for branch in ["main", "master", "develop", "trunk"] { + let output = Command::new("git") + .args(["rev-parse", "--verify", &format!("refs/heads/{}", branch)]) + .current_dir(repo_path) + .output() + .await?; + + if output.status.success() { + return Ok(branch.to_string()); + } + } + + // Fall back to getting the current branch + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(repo_path) + .output() + .await?; + + if output.status.success() { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !branch.is_empty() && branch != "HEAD" { + return Ok(branch); + } + } + + Err(WorktreeError::GitCommand( + "Could not detect default branch".to_string(), + )) + } + + /// Ensure the source repository exists locally and is up-to-date. + /// If repo_source is a URL, clone it. If it's a path, verify it exists. + /// For both cases, fetch latest changes from remote if available. + pub async fn ensure_repo(&self, repo_source: &str) -> Result { + // Check if it's a URL (simple heuristic) + if repo_source.starts_with("http://") + || repo_source.starts_with("https://") + || repo_source.starts_with("git@") + || repo_source.starts_with("ssh://") + { + self.clone_or_fetch_repo(repo_source).await + } else { + // Treat as local path - expand tilde if present + let path = expand_tilde(repo_source); + if !path.exists() { + return Err(WorktreeError::RepoNotFound(repo_source.to_string())); + } + // Verify it's a git repo + let git_dir = path.join(".git"); + if !git_dir.exists() { + return Err(WorktreeError::InvalidPath(format!( + "{} is not a git repository", + repo_source + ))); + } + + // Fetch latest changes from remote if configured + tracing::info!("Fetching latest changes for local repo: {}", repo_source); + let output = Command::new("git") + .args(["fetch", "--all", "--prune"]) + .current_dir(&path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Don't fail - repo might not have a remote configured + tracing::debug!("Git fetch for local repo (may not have remote): {}", stderr); + } else { + tracing::info!("Fetched latest changes for {}", repo_source); + } + + Ok(path) + } + } + + /// Clone a repository or fetch if already cloned. + async fn clone_or_fetch_repo(&self, url: &str) -> Result { + // Extract repo name from URL + let repo_name = extract_repo_name(url); + let repo_path = self.repos_dir.join(&repo_name); + + // Create repos directory if needed + tokio::fs::create_dir_all(&self.repos_dir).await?; + + if repo_path.exists() { + // Fetch latest changes + tracing::info!("Fetching updates for existing repo: {}", repo_name); + let output = Command::new("git") + .args(["fetch", "--all", "--prune"]) + .current_dir(&repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!("Git fetch warning: {}", stderr); + // Don't fail on fetch errors, repo might still be usable + } + } else { + // Clone the repository + tracing::info!("Cloning repository: {} -> {}", url, repo_path.display()); + let output = Command::new("git") + .args(["clone", "--bare", url]) + .arg(&repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::CloneFailed(stderr.to_string())); + } + } + + Ok(repo_path) + } + + /// Create a worktree for a task. + /// + /// This creates a unique directory with a git worktree checked out to a new branch. + pub async fn create_worktree( + &self, + source_repo: &Path, + task_id: Uuid, + task_name: &str, + base_branch: &str, + ) -> Result { + // Generate unique directory name and branch + let dir_name = format!("{}-{}", short_uuid(task_id), sanitize_name(task_name)); + let worktree_path = self.base_dir.join(&dir_name); + // Branch name: makima/{task-name-with-dashes}-{short-id} + let branch_name = format!("{}{}-{}", self.branch_prefix, sanitize_name(task_name), short_uuid(task_id)); + + // Acquire lock for this worktree path + let lock = { + let mut locks = WORKTREE_LOCKS.lock().await; + locks + .entry(worktree_path.to_string_lossy().to_string()) + .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + let _guard = lock.lock().await; + + // Check if worktree already exists - reuse it if so + if worktree_path.exists() { + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + "Worktree already exists, reusing" + ); + + // Verify it's a valid git directory + let git_dir = worktree_path.join(".git"); + if git_dir.exists() { + // Get the current branch name + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(&worktree_path) + .output() + .await?; + + let current_branch = if output.status.success() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + branch_name.clone() + }; + + return Ok(WorktreeInfo { + path: worktree_path, + branch: current_branch, + source_repo: source_repo.to_path_buf(), + }); + } else { + // Directory exists but isn't a git worktree - remove and recreate + tracing::warn!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + "Directory exists but is not a git worktree, removing" + ); + tokio::fs::remove_dir_all(&worktree_path).await?; + } + } + + // Create base directory + tokio::fs::create_dir_all(&self.base_dir).await?; + + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + branch = %branch_name, + base_branch = %base_branch, + "Creating worktree from local branch" + ); + + // Create the worktree with a new branch based on the local base_branch + let output = Command::new("git") + .args([ + "worktree", + "add", + "-b", + &branch_name, + ]) + .arg(&worktree_path) + .arg(base_branch) + .current_dir(source_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to create worktree: {}", + stderr + ))); + } + + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + "Worktree created successfully" + ); + + Ok(WorktreeInfo { + path: worktree_path, + branch: branch_name, + source_repo: source_repo.to_path_buf(), + }) + } + + /// Create a worktree for a task by copying from another task's worktree. + /// + /// This allows sequential subtasks where one continues from another's work, + /// including uncommitted changes. + pub async fn create_worktree_from_task( + &self, + source_worktree: &Path, + task_id: Uuid, + task_name: &str, + ) -> Result { + // Verify source worktree exists + if !source_worktree.exists() { + return Err(WorktreeError::RepoNotFound(format!( + "Source worktree not found: {}", + source_worktree.display() + ))); + } + + // Get the source repo from the source worktree + let source_repo = self.get_worktree_source(source_worktree).await?; + + // Get the base branch from source worktree's current HEAD + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(source_worktree) + .output() + .await?; + + if !output.status.success() { + return Err(WorktreeError::GitCommand( + "Failed to get source worktree HEAD".to_string(), + )); + } + let source_commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + // Generate unique directory name and branch for new worktree + let dir_name = format!("{}-{}", short_uuid(task_id), sanitize_name(task_name)); + let worktree_path = self.base_dir.join(&dir_name); + let branch_name = format!("{}{}", self.branch_prefix, task_id); + + // Acquire lock for this worktree path + let lock = { + let mut locks = WORKTREE_LOCKS.lock().await; + locks + .entry(worktree_path.to_string_lossy().to_string()) + .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + let _guard = lock.lock().await; + + // Remove existing worktree if present + if worktree_path.exists() { + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + "Removing existing worktree before creating from source" + ); + tokio::fs::remove_dir_all(&worktree_path).await?; + } + + // Create base directory + tokio::fs::create_dir_all(&self.base_dir).await?; + + tracing::info!( + task_id = %task_id, + source_worktree = %source_worktree.display(), + worktree_path = %worktree_path.display(), + branch = %branch_name, + source_commit = %source_commit, + "Creating worktree from source task" + ); + + // Create a new worktree based on the source commit + let output = Command::new("git") + .args([ + "worktree", + "add", + "-b", + &branch_name, + ]) + .arg(&worktree_path) + .arg(&source_commit) + .current_dir(&source_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to create worktree: {}", + stderr + ))); + } + + // Now copy uncommitted changes from source worktree + // Use rsync to copy all files except .git + let output = Command::new("rsync") + .args([ + "-a", + "--exclude", ".git", + "--exclude", ".makima", + &format!("{}/", source_worktree.display()), + &format!("{}/", worktree_path.display()), + ]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + task_id = %task_id, + "rsync warning (continuing anyway): {}", + stderr + ); + } + + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + "Worktree created from source task successfully" + ); + + Ok(WorktreeInfo { + path: worktree_path, + branch: branch_name, + source_repo: source_repo.to_path_buf(), + }) + } + + /// Remove a worktree and optionally its branch. + pub async fn remove_worktree( + &self, + worktree_path: &Path, + delete_branch: bool, + ) -> Result<(), WorktreeError> { + if !worktree_path.exists() { + return Ok(()); // Already gone + } + + // Get the branch name before removing + let branch_name = if delete_branch { + self.get_worktree_branch(worktree_path).await.ok() + } else { + None + }; + + // Find the source repo from worktree + let source_repo = self.get_worktree_source(worktree_path).await?; + + tracing::info!( + worktree_path = %worktree_path.display(), + delete_branch = delete_branch, + "Removing worktree" + ); + + // Remove the worktree + let output = Command::new("git") + .args(["worktree", "remove", "--force"]) + .arg(worktree_path) + .current_dir(&source_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Try force removal of directory if git worktree remove fails + if worktree_path.exists() { + tokio::fs::remove_dir_all(worktree_path).await?; + } + tracing::warn!("Git worktree remove warning: {}", stderr); + } + + // Prune worktree references + let _ = Command::new("git") + .args(["worktree", "prune"]) + .current_dir(&source_repo) + .output() + .await; + + // Delete the branch if requested + if let Some(branch) = branch_name { + let output = Command::new("git") + .args(["branch", "-D", &branch]) + .current_dir(&source_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!("Failed to delete branch {}: {}", branch, stderr); + } + } + + Ok(()) + } + + /// Get the branch name of a worktree. + async fn get_worktree_branch(&self, worktree_path: &Path) -> Result { + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to get branch: {}", + stderr + ))); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + + /// Get the source repository path for a worktree. + async fn get_worktree_source(&self, worktree_path: &Path) -> Result { + // Read the .git file in the worktree which contains the path to the main repo + let git_file = worktree_path.join(".git"); + + if git_file.is_file() { + let content = tokio::fs::read_to_string(&git_file).await?; + // Format: "gitdir: /path/to/repo/.git/worktrees/name" + if let Some(gitdir) = content.strip_prefix("gitdir: ") { + let gitdir = gitdir.trim(); + // Navigate from worktrees/name back to the main repo + let path = PathBuf::from(gitdir); + if let Some(worktrees_dir) = path.parent() { + if let Some(git_dir) = worktrees_dir.parent() { + if let Some(repo_dir) = git_dir.parent() { + return Ok(repo_dir.to_path_buf()); + } + } + } + } + } + + // Fallback: try to find it in our repos directory + Err(WorktreeError::InvalidPath(format!( + "Could not determine source repo for worktree: {}", + worktree_path.display() + ))) + } + + /// List all worktrees in the base directory. + pub async fn list_worktrees(&self) -> Result, WorktreeError> { + let mut worktrees = Vec::new(); + + if !self.base_dir.exists() { + return Ok(worktrees); + } + + let mut entries = tokio::fs::read_dir(&self.base_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.is_dir() && path.join(".git").exists() { + worktrees.push(path); + } + } + + Ok(worktrees) + } + + /// Initialize a new git repository for a task. + /// + /// This creates a fresh git repo (not a worktree) for tasks that don't need + /// an existing codebase. Use this when `repository_url` is `new://` or `new://project-name`. + pub async fn init_new_repo( + &self, + task_id: Uuid, + repo_source: &str, + ) -> Result { + let project_name = extract_new_repo_name(repo_source); + let dir_name = match project_name { + Some(name) => format!("{}-{}", short_uuid(task_id), sanitize_name(name)), + None => format!("{}-new", short_uuid(task_id)), + }; + let repo_path = self.repos_dir.join(&dir_name); + + tracing::info!( + task_id = %task_id, + path = %repo_path.display(), + project_name = ?project_name, + "Initializing new git repository" + ); + + // Create directory + tokio::fs::create_dir_all(&repo_path).await?; + + // git init + let output = Command::new("git") + .args(["init"]) + .current_dir(&repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to init repository: {}", + stderr + ))); + } + + // Configure git user (needed for commits) + let _ = Command::new("git") + .args(["config", "user.email", "makima@localhost"]) + .current_dir(&repo_path) + .output() + .await; + let _ = Command::new("git") + .args(["config", "user.name", "Makima"]) + .current_dir(&repo_path) + .output() + .await; + + // Initial commit (required for worktrees to work later if needed) + let output = Command::new("git") + .args(["commit", "--allow-empty", "-m", "Initial commit"]) + .current_dir(&repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to create initial commit: {}", + stderr + ))); + } + + tracing::info!( + task_id = %task_id, + path = %repo_path.display(), + "New git repository initialized" + ); + + Ok(WorktreeInfo { + path: repo_path.clone(), + branch: "main".to_string(), + source_repo: repo_path, + }) + } + + // ========== Merge Operations ========== + + /// List all task branches in a repository. + /// + /// Returns branches matching the pattern `makima/task-*`. + pub async fn list_task_branches( + &self, + repo_path: &Path, + ) -> Result, WorktreeError> { + // Get all branches matching our prefix + let output = Command::new("git") + .args([ + "branch", + "--list", + &format!("{}*", self.branch_prefix), + "--format=%(refname:short)|%(objectname:short)|%(subject)", + ]) + .current_dir(repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to list branches: {}", + stderr + ))); + } + + // Get list of merged branches + let merged_output = Command::new("git") + .args(["branch", "--merged", "HEAD", "--format=%(refname:short)"]) + .current_dir(repo_path) + .output() + .await?; + + let merged_branches: std::collections::HashSet = if merged_output.status.success() { + String::from_utf8_lossy(&merged_output.stdout) + .lines() + .map(|s| s.trim().to_string()) + .collect() + } else { + std::collections::HashSet::new() + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut branches = Vec::new(); + + for line in stdout.lines() { + let parts: Vec<&str> = line.split('|').collect(); + if parts.len() >= 3 { + let name = parts[0].trim().to_string(); + let last_commit = parts[1].trim().to_string(); + let last_commit_message = parts[2].trim().to_string(); + + // Try to extract task ID from branch name + let task_id = name + .strip_prefix(&self.branch_prefix) + .and_then(|s| Uuid::parse_str(s).ok()); + + let is_merged = merged_branches.contains(&name); + + branches.push(TaskBranchInfo { + name, + task_id, + is_merged, + last_commit, + last_commit_message, + }); + } + } + + Ok(branches) + } + + /// Start a merge of a branch into the current worktree. + /// + /// Uses `--no-commit` to allow conflict resolution before committing. + /// Returns Ok(None) if merge succeeds without conflicts, or Ok(Some(files)) + /// with the list of conflicted files. + pub async fn merge_branch( + &self, + worktree_path: &Path, + source_branch: &str, + ) -> Result>, WorktreeError> { + // Check if there's already a merge in progress + if self.is_merge_in_progress(worktree_path).await? { + return Err(WorktreeError::MergeInProgress); + } + + tracing::info!( + worktree = %worktree_path.display(), + source_branch = %source_branch, + "Starting merge" + ); + + // Attempt the merge with --no-commit --no-ff + let output = Command::new("git") + .args(["merge", "--no-commit", "--no-ff", source_branch]) + .current_dir(worktree_path) + .output() + .await?; + + if output.status.success() { + tracing::info!("Merge completed without conflicts"); + return Ok(None); + } + + // Check if there are conflicts + let conflicts = self.get_conflicted_files(worktree_path).await?; + if !conflicts.is_empty() { + tracing::info!( + conflicts = ?conflicts, + "Merge has conflicts" + ); + return Ok(Some(conflicts)); + } + + // Other error + let stderr = String::from_utf8_lossy(&output.stderr); + Err(WorktreeError::GitCommand(format!( + "Merge failed: {}", + stderr + ))) + } + + /// Check if a merge is currently in progress. + pub async fn is_merge_in_progress(&self, worktree_path: &Path) -> Result { + // Check for MERGE_HEAD file + let merge_head = worktree_path.join(".git").join("MERGE_HEAD"); + if merge_head.exists() { + return Ok(true); + } + + // Also check in .git file (for worktrees) + let git_file = worktree_path.join(".git"); + if git_file.is_file() { + if let Ok(content) = tokio::fs::read_to_string(&git_file).await { + if let Some(gitdir) = content.strip_prefix("gitdir: ") { + let gitdir = PathBuf::from(gitdir.trim()); + let merge_head = gitdir.join("MERGE_HEAD"); + if merge_head.exists() { + return Ok(true); + } + } + } + } + + Ok(false) + } + + /// Get the list of files with unresolved conflicts. + pub async fn get_conflicted_files( + &self, + worktree_path: &Path, + ) -> Result, WorktreeError> { + let output = Command::new("git") + .args(["diff", "--name-only", "--diff-filter=U"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + // No conflicts or not in merge state + return Ok(Vec::new()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let files: Vec = stdout + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + Ok(files) + } + + /// Get the current merge state. + pub async fn get_merge_state( + &self, + worktree_path: &Path, + ) -> Result { + let in_progress = self.is_merge_in_progress(worktree_path).await?; + + if !in_progress { + return Ok(MergeState { + source_branch: String::new(), + conflicted_files: Vec::new(), + in_progress: false, + }); + } + + // Get the branch being merged from MERGE_HEAD + let source_branch = self.get_merge_source_branch(worktree_path).await?; + let conflicted_files = self.get_conflicted_files(worktree_path).await?; + + Ok(MergeState { + source_branch, + conflicted_files, + in_progress: true, + }) + } + + /// Get the branch name being merged (from MERGE_HEAD). + async fn get_merge_source_branch(&self, worktree_path: &Path) -> Result { + // Get MERGE_HEAD commit + let output = Command::new("git") + .args(["rev-parse", "MERGE_HEAD"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + return Ok("unknown".to_string()); + } + + let commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + // Try to find branch name for this commit + let output = Command::new("git") + .args(["name-rev", "--name-only", &commit]) + .current_dir(worktree_path) + .output() + .await?; + + if output.status.success() { + let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); + // Clean up the name (remove ~N suffixes, etc.) + let name = name.split('~').next().unwrap_or(&name); + let name = name.split('^').next().unwrap_or(name); + return Ok(name.to_string()); + } + + Ok(commit[..8.min(commit.len())].to_string()) + } + + /// Resolve a conflict in a specific file. + pub async fn resolve_conflict( + &self, + worktree_path: &Path, + file_path: &str, + resolution: ConflictResolution, + ) -> Result<(), WorktreeError> { + if !self.is_merge_in_progress(worktree_path).await? { + return Err(WorktreeError::NoMergeInProgress); + } + + let strategy = match resolution { + ConflictResolution::Ours => "--ours", + ConflictResolution::Theirs => "--theirs", + }; + + tracing::info!( + worktree = %worktree_path.display(), + file = %file_path, + strategy = %strategy, + "Resolving conflict" + ); + + // Checkout the chosen version + let output = Command::new("git") + .args(["checkout", strategy, "--", file_path]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to resolve conflict: {}", + stderr + ))); + } + + // Stage the resolved file + let output = Command::new("git") + .args(["add", file_path]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to stage resolved file: {}", + stderr + ))); + } + + Ok(()) + } + + /// Abort the current merge. + pub async fn abort_merge(&self, worktree_path: &Path) -> Result<(), WorktreeError> { + if !self.is_merge_in_progress(worktree_path).await? { + return Err(WorktreeError::NoMergeInProgress); + } + + tracing::info!( + worktree = %worktree_path.display(), + "Aborting merge" + ); + + let output = Command::new("git") + .args(["merge", "--abort"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to abort merge: {}", + stderr + ))); + } + + Ok(()) + } + + /// Commit the current merge. + pub async fn commit_merge( + &self, + worktree_path: &Path, + message: &str, + ) -> Result { + // Check for remaining conflicts + let conflicts = self.get_conflicted_files(worktree_path).await?; + if !conflicts.is_empty() { + return Err(WorktreeError::MergeConflicts(conflicts.join(", "))); + } + + tracing::info!( + worktree = %worktree_path.display(), + message = %message, + "Committing merge" + ); + + let output = Command::new("git") + .args(["commit", "-m", message]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to commit merge: {}", + stderr + ))); + } + + // Get the new commit SHA + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(worktree_path) + .output() + .await?; + + if output.status.success() { + let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); + return Ok(sha); + } + + Ok("unknown".to_string()) + } + + // ========== Completion Action Operations ========== + + /// Push task branch from worktree to an external target repository. + /// + /// This stages and commits any uncommitted changes, then pushes to the target repo. + pub async fn push_to_target_repo( + &self, + worktree_path: &Path, + target_repo: &Path, + branch_name: &str, + task_name: &str, + ) -> Result<(), WorktreeError> { + tracing::info!( + worktree = %worktree_path.display(), + target_repo = %target_repo.display(), + branch = %branch_name, + "Pushing branch to target repository" + ); + + // First, stage all changes (including new files) + let output = Command::new("git") + .args(["add", "-A"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to stage changes: {}", + stderr + ))); + } + + // Check if there are staged changes to commit + let output = Command::new("git") + .args(["diff", "--cached", "--quiet"]) + .current_dir(worktree_path) + .output() + .await?; + + // Exit code 1 means there are staged changes + if !output.status.success() { + tracing::info!("Committing staged changes before push"); + + let commit_message = format!("feat: {}", task_name); + let output = Command::new("git") + .args(["commit", "-m", &commit_message]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to commit changes: {}", + stderr + ))); + } + } + + // Ensure there are commits to push + let output = Command::new("git") + .args(["log", "--oneline", "-1"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + return Err(WorktreeError::GitCommand( + "No commits in worktree".to_string(), + )); + } + + // Add target repo as a remote in the worktree (if not already) + let remote_name = "target"; + let target_path_str = target_repo.to_string_lossy(); + + // Remove existing remote if any (ignore errors) + let _ = Command::new("git") + .args(["remote", "remove", remote_name]) + .current_dir(worktree_path) + .output() + .await; + + // Add the target as a remote + let output = Command::new("git") + .args(["remote", "add", remote_name, &target_path_str]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to add remote: {}", + stderr + ))); + } + + // Push the branch to the target + let output = Command::new("git") + .args(["push", "-u", remote_name, &format!("HEAD:{}", branch_name)]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to push to target: {}", + stderr + ))); + } + + tracing::info!( + branch = %branch_name, + target_repo = %target_repo.display(), + "Branch pushed successfully" + ); + + // Detach HEAD in the worktree to release the branch + // This allows the branch to be checked out in the target repo + let output = Command::new("git") + .args(["checkout", "--detach", "HEAD"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + // Non-fatal: log but don't fail the push + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "Failed to detach HEAD in worktree (branch may not be checkable in target): {}", + stderr + ); + } else { + tracing::info!("Detached HEAD in worktree to release branch"); + } + + Ok(()) + } + + /// Merge a branch into the target branch in the target repository. + /// + /// This pushes the branch first (if needed), then performs a merge in the target repo. + pub async fn merge_to_target( + &self, + worktree_path: &Path, + target_repo: &Path, + source_branch: &str, + target_branch: &str, + task_name: &str, + ) -> Result { + tracing::info!( + worktree = %worktree_path.display(), + target_repo = %target_repo.display(), + source_branch = %source_branch, + target_branch = %target_branch, + "Merging branch to target" + ); + + // First, push the branch to target repo + self.push_to_target_repo(worktree_path, target_repo, source_branch, task_name) + .await?; + + // In target repo, checkout the target branch + let output = Command::new("git") + .args(["checkout", target_branch]) + .current_dir(target_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to checkout target branch: {}", + stderr + ))); + } + + // Pull latest changes first + let _ = Command::new("git") + .args(["pull", "--ff-only"]) + .current_dir(target_repo) + .output() + .await; + + // Merge the source branch + let merge_message = format!("feat: {}", task_name); + let output = Command::new("git") + .args(["merge", "--no-ff", source_branch, "-m", &merge_message]) + .current_dir(target_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + + // Check if it's a conflict + let conflicts = self.get_conflicted_files(target_repo).await?; + if !conflicts.is_empty() { + // Abort the merge + let _ = Command::new("git") + .args(["merge", "--abort"]) + .current_dir(target_repo) + .output() + .await; + + return Err(WorktreeError::MergeConflicts(format!( + "Merge conflicts in: {}. Consider creating a PR instead.", + conflicts.join(", ") + ))); + } + + return Err(WorktreeError::GitCommand(format!( + "Failed to merge: {}", + stderr + ))); + } + + // Get the merge commit SHA + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(target_repo) + .output() + .await?; + + let commit_sha = if output.status.success() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + "unknown".to_string() + }; + + tracing::info!( + commit_sha = %commit_sha, + "Merge completed successfully" + ); + + Ok(commit_sha) + } + + /// Create a GitHub pull request using the gh CLI. + /// + /// This pushes the branch first, then creates a PR. + pub async fn create_pull_request( + &self, + worktree_path: &Path, + target_repo: &Path, + source_branch: &str, + target_branch: &str, + title: &str, + body: &str, + ) -> Result { + tracing::info!( + worktree = %worktree_path.display(), + target_repo = %target_repo.display(), + source_branch = %source_branch, + target_branch = %target_branch, + title = %title, + "Creating pull request" + ); + + // First, push the branch to the target repo's remote + // For PRs, we need to push to origin (the GitHub remote) + + // Get the worktree's current branch + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(worktree_path) + .output() + .await?; + + let current_branch = if output.status.success() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + source_branch.to_string() + }; + + // Push to the target repo's origin + // First, check if target_repo has an origin remote + let output = Command::new("git") + .args(["remote", "get-url", "origin"]) + .current_dir(target_repo) + .output() + .await?; + + if !output.status.success() { + return Err(WorktreeError::GitCommand( + "Target repository has no origin remote configured".to_string(), + )); + } + + let origin_url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + // Push the branch from worktree to the remote + // First add the remote to worktree + let _ = Command::new("git") + .args(["remote", "remove", "pr-origin"]) + .current_dir(worktree_path) + .output() + .await; + + let output = Command::new("git") + .args(["remote", "add", "pr-origin", &origin_url]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to add remote: {}", + stderr + ))); + } + + // Push to the remote + let output = Command::new("git") + .args(["push", "-u", "pr-origin", &format!("{}:{}", current_branch, source_branch)]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to push branch: {}", + stderr + ))); + } + + // Create PR using gh CLI in the target repo + let output = Command::new("gh") + .args([ + "pr", + "create", + "--title", title, + "--body", body, + "--head", source_branch, + "--base", target_branch, + ]) + .current_dir(target_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to create PR: {}", + stderr + ))); + } + + // The gh CLI outputs the PR URL + let pr_url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + tracing::info!( + pr_url = %pr_url, + "Pull request created successfully" + ); + + Ok(pr_url) + } + + /// Clone/copy the worktree contents to a target directory. + /// + /// This creates a new git repository at the target path with the same contents + /// as the worktree. Returns (success, message). + pub async fn clone_worktree_to_directory( + &self, + worktree_path: &Path, + target_dir: &Path, + ) -> Result { + tracing::info!( + worktree = %worktree_path.display(), + target = %target_dir.display(), + "Cloning worktree to target directory" + ); + + // Check if target directory already exists + if target_dir.exists() { + return Err(WorktreeError::AlreadyExists(format!( + "Target directory already exists: {}", + target_dir.display() + ))); + } + + // Get parent directory to ensure it exists + if let Some(parent) = target_dir.parent() { + if !parent.exists() { + tokio::fs::create_dir_all(parent).await?; + } + } + + // Use git clone --local to efficiently copy the repository + // This is more efficient than cp -r for git repos + let output = Command::new("git") + .args([ + "clone", + "--local", + "--no-hardlinks", + &worktree_path.to_string_lossy(), + &target_dir.to_string_lossy(), + ]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::CloneFailed(format!( + "Failed to clone worktree: {}", + stderr + ))); + } + + // Remove the 'origin' remote that points back to the worktree + let _ = Command::new("git") + .args(["remote", "remove", "origin"]) + .current_dir(target_dir) + .output() + .await; + + tracing::info!( + target = %target_dir.display(), + "Worktree cloned successfully" + ); + + Ok(format!("Cloned to {}", target_dir.display())) + } + + /// Check if a target directory exists. + pub async fn target_directory_exists(&self, target_dir: &Path) -> bool { + target_dir.exists() + } +} + +/// Check if repo_source is a "new repo" request. +/// +/// Accepts `new://` or `new://project-name` to create a fresh git repository. +pub fn is_new_repo_request(source: &str) -> bool { + source == "new" || source == "new://" || source.starts_with("new://") +} + +/// Extract optional project name from new:// URL. +fn extract_new_repo_name(source: &str) -> Option<&str> { + source.strip_prefix("new://").filter(|s| !s.is_empty()) +} + +/// Extract repository name from URL. +fn extract_repo_name(url: &str) -> String { + // Handle various URL formats: + // https://github.com/user/repo.git -> repo + // git@github.com:user/repo.git -> repo + // https://github.com/user/repo -> repo + + let url = url.trim_end_matches('/'); + let url = url.trim_end_matches(".git"); + + url.rsplit('/') + .next() + .or_else(|| url.rsplit(':').next()) + .unwrap_or("repo") + .to_string() +} + +/// Create a short UUID string for directory naming. +pub fn short_uuid(id: Uuid) -> String { + id.to_string()[..8].to_string() +} + +/// Expand tilde (~) in path to home directory. +pub fn expand_tilde(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } else if path == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + } + PathBuf::from(path) +} + +/// Sanitize a name for use in directory/branch names. +pub fn sanitize_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .chars() + .take(50) // Limit length + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_repo_name() { + assert_eq!( + extract_repo_name("https://github.com/user/repo.git"), + "repo" + ); + assert_eq!( + extract_repo_name("https://github.com/user/repo"), + "repo" + ); + assert_eq!( + extract_repo_name("git@github.com:user/repo.git"), + "repo" + ); + } + + #[test] + fn test_sanitize_name() { + assert_eq!(sanitize_name("Hello World!"), "hello-world-"); + assert_eq!(sanitize_name("test_name-123"), "test_name-123"); + assert_eq!(sanitize_name("A".repeat(100).as_str()).len(), 50); + } + + #[test] + fn test_short_uuid() { + let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + assert_eq!(short_uuid(id), "550e8400"); + } +} diff --git a/makima/daemon/src/worktree/mod.rs b/makima/daemon/src/worktree/mod.rs new file mode 100644 index 0000000..eb9f031 --- /dev/null +++ b/makima/daemon/src/worktree/mod.rs @@ -0,0 +1,11 @@ +//! Git worktree management for task isolation. +//! +//! Each task gets a unique git worktree with its own branch, +//! providing isolation without the overhead of Docker containers. + +mod manager; + +pub use manager::{ + expand_tilde, is_new_repo_request, sanitize_name, short_uuid, ConflictResolution, MergeState, + TaskBranchInfo, WorktreeError, WorktreeInfo, WorktreeManager, +}; diff --git a/makima/daemon/src/ws/client.rs b/makima/daemon/src/ws/client.rs new file mode 100644 index 0000000..ba1263f --- /dev/null +++ b/makima/daemon/src/ws/client.rs @@ -0,0 +1,290 @@ +//! WebSocket client for connecting to the makima server. + +use std::sync::Arc; +use std::time::Duration; + +use backoff::backoff::Backoff; +use backoff::ExponentialBackoff; +use futures::{SinkExt, StreamExt}; +use tokio::sync::{mpsc, RwLock}; +use tokio_tungstenite::{connect_async, tungstenite::{client::IntoClientRequest, Message}}; +use uuid::Uuid; + +use super::protocol::{DaemonCommand, DaemonMessage}; +use crate::config::ServerConfig; +use crate::error::{DaemonError, Result}; + +/// WebSocket client state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionState { + /// Not connected to server. + Disconnected, + /// Currently connecting. + Connecting, + /// Connected and authenticated. + Connected, + /// Connection failed, will retry. + Reconnecting, + /// Permanently failed (e.g., auth failure). + Failed, +} + +/// WebSocket client for daemon-server communication. +pub struct WsClient { + config: ServerConfig, + machine_id: String, + hostname: String, + max_concurrent_tasks: i32, + state: Arc>, + daemon_id: Arc>>, + /// Channel to receive messages to send to server. + outgoing_rx: mpsc::Receiver, + /// Sender for outgoing messages (clone this to send messages). + outgoing_tx: mpsc::Sender, + /// Channel to send received commands to the task manager. + incoming_tx: mpsc::Sender, +} + +impl WsClient { + /// Create a new WebSocket client. + pub fn new( + config: ServerConfig, + machine_id: String, + hostname: String, + max_concurrent_tasks: i32, + incoming_tx: mpsc::Sender, + ) -> Self { + let (outgoing_tx, outgoing_rx) = mpsc::channel(256); + + Self { + config, + machine_id, + hostname, + max_concurrent_tasks, + state: Arc::new(RwLock::new(ConnectionState::Disconnected)), + daemon_id: Arc::new(RwLock::new(None)), + outgoing_rx, + outgoing_tx, + incoming_tx, + } + } + + /// Get a sender for outgoing messages. + pub fn sender(&self) -> mpsc::Sender { + self.outgoing_tx.clone() + } + + /// Get current connection state. + pub async fn state(&self) -> ConnectionState { + *self.state.read().await + } + + /// Get daemon ID if authenticated. + pub async fn daemon_id(&self) -> Option { + *self.daemon_id.read().await + } + + /// Run the WebSocket client with automatic reconnection. + pub async fn run(&mut self) -> Result<()> { + let mut backoff = ExponentialBackoff { + initial_interval: Duration::from_secs(self.config.reconnect_interval_secs), + max_interval: Duration::from_secs(60), + max_elapsed_time: if self.config.max_reconnect_attempts > 0 { + Some(Duration::from_secs( + self.config.reconnect_interval_secs * self.config.max_reconnect_attempts as u64 * 10, + )) + } else { + None // Infinite retries + }, + ..Default::default() + }; + + loop { + *self.state.write().await = ConnectionState::Connecting; + tracing::info!("Connecting to server: {}", self.config.url); + + match self.connect_and_run().await { + Ok(()) => { + // Clean shutdown + tracing::info!("WebSocket connection closed cleanly"); + break; + } + Err(DaemonError::AuthFailed(msg)) => { + tracing::error!("Authentication failed: {}", msg); + *self.state.write().await = ConnectionState::Failed; + return Err(DaemonError::AuthFailed(msg)); + } + Err(e) => { + tracing::warn!("Connection error: {}", e); + *self.state.write().await = ConnectionState::Reconnecting; + + if let Some(delay) = backoff.next_backoff() { + tracing::info!("Reconnecting in {:?}...", delay); + tokio::time::sleep(delay).await; + } else { + tracing::error!("Max reconnection attempts reached"); + *self.state.write().await = ConnectionState::Failed; + return Err(DaemonError::ConnectionLost); + } + } + } + } + + Ok(()) + } + + /// Connect to server and run the message loop. + async fn connect_and_run(&mut self) -> Result<()> { + // Build WebSocket URL + let ws_url = format!("{}/api/v1/mesh/daemons/connect", self.config.url); + tracing::debug!("Connecting to WebSocket: {}", ws_url); + + // Build request with API key header + let mut request = ws_url.into_client_request()?; + request.headers_mut().insert( + "x-makima-api-key", + self.config.api_key.parse().map_err(|_| { + DaemonError::AuthFailed("Invalid API key format".into()) + })?, + ); + + // Connect with API key in headers + let (ws_stream, _response) = connect_async(request).await?; + let (mut write, mut read) = ws_stream.split(); + + // Send daemon info after connection (server authenticated us via header) + let info_msg = DaemonMessage::authenticate( + &self.config.api_key, + &self.machine_id, + &self.hostname, + self.max_concurrent_tasks, + ); + let info_json = serde_json::to_string(&info_msg)?; + write.send(Message::Text(info_json)).await?; + + // Wait for authentication response + let auth_response = read + .next() + .await + .ok_or(DaemonError::ConnectionLost)??; + + let auth_text = match auth_response { + Message::Text(text) => text, + Message::Close(_) => return Err(DaemonError::ConnectionLost), + _ => return Err(DaemonError::AuthFailed("Unexpected response type".into())), + }; + + let command: DaemonCommand = serde_json::from_str(&auth_text)?; + match command { + DaemonCommand::Authenticated { daemon_id } => { + tracing::info!("Authenticated with daemon ID: {}", daemon_id); + *self.daemon_id.write().await = Some(daemon_id); + *self.state.write().await = ConnectionState::Connected; + + // Send daemon directories info to server + let working_directory = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| ".".to_string()); + let home_directory = dirs::home_dir() + .map(|h| h.join(".makima").join("home")) + .unwrap_or_else(|| std::path::PathBuf::from("~/.makima/home")); + // Create home directory if it doesn't exist + if let Err(e) = std::fs::create_dir_all(&home_directory) { + tracing::warn!("Failed to create home directory {:?}: {}", home_directory, e); + } + let home_directory_str = home_directory.to_string_lossy().to_string(); + let worktrees_directory = dirs::home_dir() + .map(|h| h.join(".makima").join("worktrees").to_string_lossy().to_string()) + .unwrap_or_else(|| "~/.makima/worktrees".to_string()); + + let dirs_msg = DaemonMessage::DaemonDirectories { + working_directory, + home_directory: home_directory_str, + worktrees_directory, + }; + let dirs_json = serde_json::to_string(&dirs_msg)?; + write.send(Message::Text(dirs_json)).await?; + tracing::info!("Sent daemon directories info to server"); + } + DaemonCommand::Error { code, message } => { + return Err(DaemonError::AuthFailed(format!("{}: {}", code, message))); + } + _ => { + return Err(DaemonError::AuthFailed( + "Unexpected response to authentication".into(), + )); + } + } + + // Start main message loop + let heartbeat_interval = Duration::from_secs(self.config.heartbeat_interval_secs); + let mut heartbeat_timer = tokio::time::interval(heartbeat_interval); + + loop { + tokio::select! { + // Handle incoming server commands + msg = read.next() => { + match msg { + Some(Ok(Message::Text(text))) => { + tracing::info!("Received WebSocket message: {} bytes", text.len()); + match serde_json::from_str::(&text) { + Ok(command) => { + tracing::info!("Parsed command: {:?}", command); + tracing::info!("Sending command to task manager channel..."); + if self.incoming_tx.send(command).await.is_err() { + tracing::warn!("Command receiver dropped, shutting down"); + break; + } + tracing::info!("Command sent to task manager successfully"); + } + Err(e) => { + tracing::warn!("Failed to parse server message: {}", e); + tracing::debug!("Raw message: {}", text); + } + } + } + Some(Ok(Message::Ping(data))) => { + write.send(Message::Pong(data)).await?; + } + Some(Ok(Message::Close(_))) | None => { + tracing::info!("Server closed connection"); + return Err(DaemonError::ConnectionLost); + } + Some(Err(e)) => { + tracing::warn!("WebSocket error: {}", e); + return Err(e.into()); + } + _ => {} + } + } + + // Handle outgoing messages + msg = self.outgoing_rx.recv() => { + match msg { + Some(message) => { + let json = serde_json::to_string(&message)?; + tracing::trace!("Sending message: {}", json); + write.send(Message::Text(json)).await?; + } + None => { + // Sender dropped, shutdown + tracing::info!("Outgoing channel closed, shutting down"); + break; + } + } + } + + // Send heartbeat + _ = heartbeat_timer.tick() => { + // Get active task IDs from task manager + // For now, send empty list - will be connected to task manager + let heartbeat = DaemonMessage::heartbeat(vec![]); + let json = serde_json::to_string(&heartbeat)?; + write.send(Message::Text(json)).await?; + } + } + } + + Ok(()) + } +} diff --git a/makima/daemon/src/ws/mod.rs b/makima/daemon/src/ws/mod.rs new file mode 100644 index 0000000..5a0e9d1 --- /dev/null +++ b/makima/daemon/src/ws/mod.rs @@ -0,0 +1,7 @@ +//! WebSocket client and protocol types for daemon-server communication. + +pub mod client; +pub mod protocol; + +pub use client::{ConnectionState, WsClient}; +pub use protocol::{BranchInfo, DaemonCommand, DaemonMessage}; diff --git a/makima/daemon/src/ws/protocol.rs b/makima/daemon/src/ws/protocol.rs new file mode 100644 index 0000000..7c2ad6d --- /dev/null +++ b/makima/daemon/src/ws/protocol.rs @@ -0,0 +1,511 @@ +//! Protocol types for daemon-server communication. +//! +//! These types mirror the server's protocol exactly for compatibility. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Message from daemon to server. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum DaemonMessage { + /// Authentication request (first message required). + Authenticate { + #[serde(rename = "apiKey")] + api_key: String, + #[serde(rename = "machineId")] + machine_id: String, + hostname: String, + #[serde(rename = "maxConcurrentTasks")] + max_concurrent_tasks: i32, + }, + + /// Periodic heartbeat with current status. + Heartbeat { + #[serde(rename = "activeTasks")] + active_tasks: Vec, + }, + + /// Task output streaming (stdout/stderr from Claude Code). + TaskOutput { + #[serde(rename = "taskId")] + task_id: Uuid, + output: String, + #[serde(rename = "isPartial")] + is_partial: bool, + }, + + /// Task status change notification. + TaskStatusChange { + #[serde(rename = "taskId")] + task_id: Uuid, + #[serde(rename = "oldStatus")] + old_status: String, + #[serde(rename = "newStatus")] + new_status: String, + }, + + /// Task progress update with summary. + TaskProgress { + #[serde(rename = "taskId")] + task_id: Uuid, + summary: String, + }, + + /// Task completion notification. + TaskComplete { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + error: Option, + }, + + /// Register a tool key for orchestrator API access. + RegisterToolKey { + #[serde(rename = "taskId")] + task_id: Uuid, + /// The API key for this orchestrator to use when calling mesh endpoints. + key: String, + }, + + /// Revoke a tool key when task completes. + RevokeToolKey { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + + // ========================================================================= + // Merge Response Messages (sent by daemon after processing merge commands) + // ========================================================================= + + /// Response to ListBranches command. + BranchList { + #[serde(rename = "taskId")] + task_id: Uuid, + branches: Vec, + }, + + /// Response to MergeStatus command. + MergeStatusResponse { + #[serde(rename = "taskId")] + task_id: Uuid, + #[serde(rename = "inProgress")] + in_progress: bool, + #[serde(rename = "sourceBranch")] + source_branch: Option, + #[serde(rename = "conflictedFiles")] + conflicted_files: Vec, + }, + + /// Response to merge operations (MergeStart, MergeResolve, MergeCommit, MergeAbort). + MergeResult { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + message: String, + #[serde(rename = "commitSha")] + commit_sha: Option, + /// Present only when conflicts occurred. + conflicts: Option>, + }, + + /// Response to CheckMergeComplete command. + MergeCompleteCheck { + #[serde(rename = "taskId")] + task_id: Uuid, + #[serde(rename = "canComplete")] + can_complete: bool, + #[serde(rename = "unmergedBranches")] + unmerged_branches: Vec, + #[serde(rename = "mergedCount")] + merged_count: u32, + #[serde(rename = "skippedCount")] + skipped_count: u32, + }, + + // ========================================================================= + // Completion Action Response Messages + // ========================================================================= + + /// Response to RetryCompletionAction command. + CompletionActionResult { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + message: String, + /// PR URL if action was "pr" and successful. + #[serde(rename = "prUrl")] + pr_url: Option, + }, + + /// Report daemon's available directories for task output. + DaemonDirectories { + /// Current working directory of the daemon. + #[serde(rename = "workingDirectory")] + working_directory: String, + /// Path to ~/.makima/home directory (for cloning completed work). + #[serde(rename = "homeDirectory")] + home_directory: String, + /// Path to worktrees directory (~/.makima/worktrees). + #[serde(rename = "worktreesDirectory")] + worktrees_directory: String, + }, + + /// Response to CloneWorktree command. + CloneWorktreeResult { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + message: String, + /// The path where the worktree was cloned. + #[serde(rename = "targetDir")] + target_dir: Option, + }, + + /// Response to CheckTargetExists command. + CheckTargetExistsResult { + #[serde(rename = "taskId")] + task_id: Uuid, + /// Whether the target directory exists. + exists: bool, + /// The path that was checked. + #[serde(rename = "targetDir")] + target_dir: String, + }, +} + +/// Information about a branch (used in BranchList message). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BranchInfo { + /// Full branch name. + pub name: String, + /// Task ID extracted from branch name (if parseable). + #[serde(rename = "taskId")] + pub task_id: Option, + /// Whether this branch has been merged. + #[serde(rename = "isMerged")] + pub is_merged: bool, + /// Short SHA of the last commit. + #[serde(rename = "lastCommit")] + pub last_commit: String, + /// Subject line of the last commit. + #[serde(rename = "lastCommitMessage")] + pub last_commit_message: String, +} + +/// Command from server to daemon. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum DaemonCommand { + /// Confirm successful authentication. + Authenticated { + #[serde(rename = "daemonId")] + daemon_id: Uuid, + }, + + /// Spawn a new task in a container. + SpawnTask { + #[serde(rename = "taskId")] + task_id: Uuid, + /// Human-readable task name (used for commit messages). + #[serde(rename = "taskName")] + task_name: String, + plan: String, + #[serde(rename = "repoUrl")] + repo_url: Option, + #[serde(rename = "baseBranch")] + base_branch: Option, + /// Target branch to merge into (used for completion actions). + #[serde(rename = "targetBranch")] + target_branch: Option, + /// Parent task ID if this is a subtask. + #[serde(rename = "parentTaskId")] + parent_task_id: Option, + /// Depth in task hierarchy (0=top-level, 1=subtask, 2=sub-subtask). + depth: i32, + /// Whether this task should run as an orchestrator (true if depth==0 and has subtasks). + #[serde(rename = "isOrchestrator")] + is_orchestrator: bool, + /// Path to user's local repository (outside ~/.makima) for completion actions. + #[serde(rename = "targetRepoPath")] + target_repo_path: Option, + /// Action on completion: "none", "branch", "merge", "pr". + #[serde(rename = "completionAction")] + completion_action: Option, + /// Task ID to continue from (copy worktree from this task). + #[serde(rename = "continueFromTaskId")] + continue_from_task_id: Option, + /// Files to copy from parent task's worktree. + #[serde(rename = "copyFiles")] + copy_files: Option>, + }, + + /// Pause a running task. + PauseTask { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + + /// Resume a paused task. + ResumeTask { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + + /// Interrupt a task (gracefully or forced). + InterruptTask { + #[serde(rename = "taskId")] + task_id: Uuid, + graceful: bool, + }, + + /// Send a message to a running task. + SendMessage { + #[serde(rename = "taskId")] + task_id: Uuid, + message: String, + }, + + /// Inject context about sibling task progress. + InjectSiblingContext { + #[serde(rename = "taskId")] + task_id: Uuid, + #[serde(rename = "siblingTaskId")] + sibling_task_id: Uuid, + #[serde(rename = "siblingName")] + sibling_name: String, + #[serde(rename = "siblingStatus")] + sibling_status: String, + #[serde(rename = "progressSummary")] + progress_summary: Option, + #[serde(rename = "changedFiles")] + changed_files: Vec, + }, + + // ========================================================================= + // Merge Commands (for orchestrators to merge subtask branches) + // ========================================================================= + + /// List all subtask branches for a task. + ListBranches { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + + /// Start merging a subtask branch. + MergeStart { + #[serde(rename = "taskId")] + task_id: Uuid, + #[serde(rename = "sourceBranch")] + source_branch: String, + }, + + /// Get current merge status. + MergeStatus { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + + /// Resolve a merge conflict. + MergeResolve { + #[serde(rename = "taskId")] + task_id: Uuid, + file: String, + /// "ours" or "theirs" + strategy: String, + }, + + /// Commit the current merge. + MergeCommit { + #[serde(rename = "taskId")] + task_id: Uuid, + message: String, + }, + + /// Abort the current merge. + MergeAbort { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + + /// Skip merging a subtask branch (mark as intentionally not merged). + MergeSkip { + #[serde(rename = "taskId")] + task_id: Uuid, + #[serde(rename = "subtaskId")] + subtask_id: Uuid, + reason: String, + }, + + /// Check if all subtask branches have been merged or skipped (completion gate). + CheckMergeComplete { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + + // ========================================================================= + // Completion Action Commands + // ========================================================================= + + /// Retry a completion action for a completed task. + RetryCompletionAction { + #[serde(rename = "taskId")] + task_id: Uuid, + /// Human-readable task name (used for commit messages). + #[serde(rename = "taskName")] + task_name: String, + /// The action to execute: "branch", "merge", or "pr". + action: String, + /// Path to the target repository. + #[serde(rename = "targetRepoPath")] + target_repo_path: String, + /// Target branch to merge into (for merge/pr actions). + #[serde(rename = "targetBranch")] + target_branch: Option, + }, + + /// Clone worktree to a target directory. + CloneWorktree { + #[serde(rename = "taskId")] + task_id: Uuid, + /// Path to the target directory. + #[serde(rename = "targetDir")] + target_dir: String, + }, + + /// Check if a target directory exists. + CheckTargetExists { + #[serde(rename = "taskId")] + task_id: Uuid, + /// Path to check. + #[serde(rename = "targetDir")] + target_dir: String, + }, + + /// Error response. + Error { + code: String, + message: String, + }, +} + +impl DaemonMessage { + /// Create an authentication message. + pub fn authenticate( + api_key: &str, + machine_id: &str, + hostname: &str, + max_concurrent_tasks: i32, + ) -> Self { + Self::Authenticate { + api_key: api_key.to_string(), + machine_id: machine_id.to_string(), + hostname: hostname.to_string(), + max_concurrent_tasks, + } + } + + /// Create a heartbeat message. + pub fn heartbeat(active_tasks: Vec) -> Self { + Self::Heartbeat { active_tasks } + } + + /// Create a task output message. + pub fn task_output(task_id: Uuid, output: String, is_partial: bool) -> Self { + Self::TaskOutput { + task_id, + output, + is_partial, + } + } + + /// Create a task status change message. + pub fn task_status_change(task_id: Uuid, old_status: &str, new_status: &str) -> Self { + Self::TaskStatusChange { + task_id, + old_status: old_status.to_string(), + new_status: new_status.to_string(), + } + } + + /// Create a task progress message. + pub fn task_progress(task_id: Uuid, summary: String) -> Self { + Self::TaskProgress { task_id, summary } + } + + /// Create a task complete message. + pub fn task_complete(task_id: Uuid, success: bool, error: Option) -> Self { + Self::TaskComplete { + task_id, + success, + error, + } + } + + /// Create a register tool key message. + pub fn register_tool_key(task_id: Uuid, key: String) -> Self { + Self::RegisterToolKey { task_id, key } + } + + /// Create a revoke tool key message. + pub fn revoke_tool_key(task_id: Uuid) -> Self { + Self::RevokeToolKey { task_id } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_daemon_message_serialization() { + let msg = DaemonMessage::authenticate("key123", "machine-abc", "worker-1", 4); + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"type\":\"authenticate\"")); + assert!(json.contains("\"apiKey\":\"key123\"")); + assert!(json.contains("\"machineId\":\"machine-abc\"")); + } + + #[test] + fn test_daemon_command_deserialization() { + let json = r#"{"type":"spawnTask","taskId":"550e8400-e29b-41d4-a716-446655440000","plan":"Build the feature","repoUrl":"https://github.com/test/repo","baseBranch":"main","parentTaskId":null,"depth":0,"isOrchestrator":false}"#; + let cmd: DaemonCommand = serde_json::from_str(json).unwrap(); + match cmd { + DaemonCommand::SpawnTask { + plan, + repo_url, + base_branch, + parent_task_id, + depth, + is_orchestrator, + .. + } => { + assert_eq!(plan, "Build the feature"); + assert_eq!(repo_url, Some("https://github.com/test/repo".to_string())); + assert_eq!(base_branch, Some("main".to_string())); + assert_eq!(parent_task_id, None); + assert_eq!(depth, 0); + assert!(!is_orchestrator); + } + _ => panic!("Expected SpawnTask"), + } + } + + #[test] + fn test_orchestrator_spawn_deserialization() { + let json = r#"{"type":"spawnTask","taskId":"550e8400-e29b-41d4-a716-446655440000","plan":"Coordinate subtasks","repoUrl":"https://github.com/test/repo","baseBranch":"main","parentTaskId":null,"depth":0,"isOrchestrator":true}"#; + let cmd: DaemonCommand = serde_json::from_str(json).unwrap(); + match cmd { + DaemonCommand::SpawnTask { + is_orchestrator, + depth, + .. + } => { + assert!(is_orchestrator); + assert_eq!(depth, 0); + } + _ => panic!("Expected SpawnTask"), + } + } +} diff --git a/makima/frontend/package-lock.json b/makima/frontend/package-lock.json index e305d2a..88297c2 100644 --- a/makima/frontend/package-lock.json +++ b/makima/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "makima-frontend", "version": "0.1.0", "dependencies": { + "@supabase/supabase-js": "^2.90.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^7.1.0", @@ -1406,6 +1407,80 @@ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" }, + "node_modules/@supabase/auth-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.90.1.tgz", + "integrity": "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.90.1.tgz", + "integrity": "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.90.1.tgz", + "integrity": "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.90.1.tgz", + "integrity": "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.90.1.tgz", + "integrity": "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.90.1.tgz", + "integrity": "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==", + "dependencies": { + "@supabase/auth-js": "2.90.1", + "@supabase/functions-js": "2.90.1", + "@supabase/postgrest-js": "2.90.1", + "@supabase/realtime-js": "2.90.1", + "@supabase/storage-js": "2.90.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", @@ -1764,6 +1839,19 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, + "node_modules/@types/node": { + "version": "25.0.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.5.tgz", + "integrity": "sha512-FuLxeLuSVOqHPxSN1fkcD8DLU21gAP7nCKqGRJ/FglbCUBs0NYN6TpHcdmyLeh8C0KwGIaZQJSv+OYG+KZz+Gw==", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==" + }, "node_modules/@types/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", @@ -1787,6 +1875,14 @@ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2277,6 +2373,14 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", @@ -3002,6 +3106,11 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3015,6 +3124,11 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3190,6 +3304,26 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/makima/frontend/package.json b/makima/frontend/package.json index ef99c3d..9293f65 100644 --- a/makima/frontend/package.json +++ b/makima/frontend/package.json @@ -11,6 +11,7 @@ "preview": "vite preview" }, "dependencies": { + "@supabase/supabase-js": "^2.90.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^7.1.0", diff --git a/makima/frontend/pnpm-lock.yaml b/makima/frontend/pnpm-lock.yaml index afb6e93..3044ad7 100644 --- a/makima/frontend/pnpm-lock.yaml +++ b/makima/frontend/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + '@supabase/supabase-js': + specifier: ^2.90.1 + version: 2.90.1 react: specifier: ^19.0.0 version: 19.2.3 @@ -938,6 +941,62 @@ packages: resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} dev: false + /@supabase/auth-js@2.90.1: + resolution: {integrity: sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==} + engines: {node: '>=20.0.0'} + dependencies: + tslib: 2.8.1 + dev: false + + /@supabase/functions-js@2.90.1: + resolution: {integrity: sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==} + engines: {node: '>=20.0.0'} + dependencies: + tslib: 2.8.1 + dev: false + + /@supabase/postgrest-js@2.90.1: + resolution: {integrity: sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==} + engines: {node: '>=20.0.0'} + dependencies: + tslib: 2.8.1 + dev: false + + /@supabase/realtime-js@2.90.1: + resolution: {integrity: sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==} + engines: {node: '>=20.0.0'} + dependencies: + '@types/phoenix': 1.6.7 + '@types/ws': 8.18.1 + tslib: 2.8.1 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: false + + /@supabase/storage-js@2.90.1: + resolution: {integrity: sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==} + engines: {node: '>=20.0.0'} + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + dev: false + + /@supabase/supabase-js@2.90.1: + resolution: {integrity: sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==} + engines: {node: '>=20.0.0'} + dependencies: + '@supabase/auth-js': 2.90.1 + '@supabase/functions-js': 2.90.1 + '@supabase/postgrest-js': 2.90.1 + '@supabase/realtime-js': 2.90.1 + '@supabase/storage-js': 2.90.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: false + /@tailwindcss/node@4.1.18: resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} dependencies: @@ -1168,6 +1227,16 @@ packages: resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} dev: true + /@types/node@25.0.5: + resolution: {integrity: sha512-FuLxeLuSVOqHPxSN1fkcD8DLU21gAP7nCKqGRJ/FglbCUBs0NYN6TpHcdmyLeh8C0KwGIaZQJSv+OYG+KZz+Gw==} + dependencies: + undici-types: 7.16.0 + dev: false + + /@types/phoenix@1.6.7: + resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} + dev: false + /@types/react-dom@19.2.3(@types/react@19.2.7): resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1185,6 +1254,12 @@ packages: resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} dev: false + /@types/ws@8.18.1: + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + dependencies: + '@types/node': 25.0.5 + dev: false + /@vitejs/plugin-react@4.7.0(vite@6.4.1): resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1482,6 +1557,11 @@ packages: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} dev: true + /iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + dev: false + /immer@10.2.0: resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} dev: false @@ -1904,12 +1984,20 @@ packages: picomatch: 4.0.3 dev: true + /tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + dev: false + /typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true dev: true + /undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + dev: false + /update-browserslist-db@1.2.3(browserslist@4.28.1): resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -2034,6 +2122,19 @@ packages: fsevents: 2.3.3 dev: true + /ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} dev: true diff --git a/makima/frontend/src/components/Masthead.tsx b/makima/frontend/src/components/Masthead.tsx index 803e45a..afe385e 100644 --- a/makima/frontend/src/components/Masthead.tsx +++ b/makima/frontend/src/components/Masthead.tsx @@ -18,7 +18,7 @@ export function Masthead({ showTicker = false, showNav = true }: MastheadProps) makima.jp - Listening System + Control System @@ -29,10 +29,10 @@ export function Masthead({ showTicker = false, showNav = true }: MastheadProps)
- /// MAKIMA LISTENING SYSTEM // MESH LATTICE FOR CONTESTED DOMAINS /// - TRANSPORT: WEBSOCKET /// ENCODING: PCM32F /// STATUS: ONLINE /// - MAKIMA.JP /// MAKIMA LISTENING SYSTEM // MESH LATTICE FOR CONTESTED DOMAINS - /// TRANSPORT: WEBSOCKET /// ENCODING: PCM32F /// STATUS: ONLINE /// + /// MAKIMA CONTROL SYSTEM // MESH ORCHESTRATION PLATFORM /// + TRANSPORT: WEBSOCKET /// DAEMONS: ACTIVE /// STATUS: ONLINE /// + MAKIMA.JP /// MAKIMA CONTROL SYSTEM // MESH ORCHESTRATION PLATFORM + /// TRANSPORT: WEBSOCKET /// DAEMONS: ACTIVE /// STATUS: ONLINE /// MAKIMA.JP ///
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 4e90d4d..806f0c5 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -1,3 +1,4 @@ +import { useAuth } from "../contexts/AuthContext"; import { RewriteLink } from "./RewriteLink"; interface NavLink { @@ -10,12 +11,17 @@ interface NavLink { const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, { label: "Files", href: "/files" }, - { label: "Mesh", href: "/mesh", disabled: true }, - { label: "Register", href: "/register", disabled: true }, - { label: "Login", href: "/login", disabled: true }, + { label: "Mesh", href: "/mesh" }, ]; export function NavStrip() { + const { isAuthenticated, isAuthConfigured, signOut, user } = useAuth(); + + const handleSignOut = async () => { + await signOut(); + window.location.href = "/login"; + }; + return ( ); } diff --git a/makima/frontend/src/components/ProtectedRoute.tsx b/makima/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..32ac592 --- /dev/null +++ b/makima/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,26 @@ +import { Navigate } from "react-router"; +import { useAuth } from "../contexts/AuthContext"; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { isAuthenticated, isLoading, isAuthConfigured } = useAuth(); + + // Show loading state while checking auth + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + // If auth is configured but user is not authenticated, redirect to login + if (isAuthConfigured && !isAuthenticated) { + return ; + } + + return <>{children}; +} diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx index 867fc4c..cf99fde 100644 --- a/makima/frontend/src/components/files/BodyRenderer.tsx +++ b/makima/frontend/src/components/files/BodyRenderer.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect } from "react"; import type { BodyElement } from "../../lib/api"; import { ChartRenderer } from "../charts/ChartRenderer"; +import { ElementContextMenu } from "./ElementContextMenu"; interface BodyRendererProps { elements: BodyElement[]; @@ -10,11 +11,54 @@ interface BodyRendererProps { onEditingChange?: (isEditing: boolean) => void; hasPendingRemoteUpdate?: boolean; onOverwrite?: () => void; + onFocusElement?: (index: number) => void; + onDeleteElement?: (index: number) => void; + onDuplicateElement?: (index: number) => void; + onConvertElement?: (index: number, toType: string) => void; + onGenerateFromElement?: (index: number, action: string) => void; + onCreateTaskFromElement?: (index: number, selectedText?: string) => void; } -export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder, onEditingChange, hasPendingRemoteUpdate, onOverwrite }: BodyRendererProps) { +export function BodyRenderer({ + elements, + isEditing = false, + onUpdate, + onReorder, + onEditingChange, + hasPendingRemoteUpdate, + onOverwrite, + onFocusElement, + onDeleteElement, + onDuplicateElement, + onConvertElement, + onGenerateFromElement, + onCreateTaskFromElement, +}: BodyRendererProps) { const [draggedIndex, setDraggedIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + elementIndex: number; + selectedText?: string; + } | null>(null); + + const handleContextMenu = (index: number) => (e: React.MouseEvent) => { + e.preventDefault(); + // Get any selected text + const selection = window.getSelection(); + const selectedText = selection?.toString().trim() || undefined; + setContextMenu({ + x: e.clientX, + y: e.clientY, + elementIndex: index, + selectedText, + }); + }; + + const closeContextMenu = () => { + setContextMenu(null); + }; if (elements.length === 0) { return ( @@ -73,6 +117,7 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder, onDragOver={handleDragOver(index)} onDragLeave={handleDragLeave} onDrop={handleDrop(index)} + onContextMenu={handleContextMenu(index)} > {/* Drag handle - only show in edit mode */} {isEditing && onReorder && ( @@ -109,6 +154,24 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder,
))} + + {/* Context Menu */} + {contextMenu && ( + onFocusElement?.(index)} + onDelete={(index) => onDeleteElement?.(index)} + onDuplicate={(index) => onDuplicateElement?.(index)} + onConvert={(index, toType) => onConvertElement?.(index, toType)} + onGenerate={(index, action) => onGenerateFromElement?.(index, action)} + onCreateTask={(index, selectedText) => onCreateTaskFromElement?.(index, selectedText)} + /> + )} ); } @@ -156,6 +219,20 @@ function BodyElementRenderer({ onOverwrite={onOverwrite} /> ); + case "code": + return ( + + ); + case "list": + return ( + + ); case "chart": return ( ); } + +function CodeElement({ + language, + content, +}: { + language?: string; + content: string; +}) { + return ( +
+ {language && ( +
+ {language} +
+ )} +
+        
+          {content}
+        
+      
+
+ ); +} + +function ListElement({ + ordered, + items, +}: { + ordered: boolean; + items: string[]; +}) { + const ListTag = ordered ? "ol" : "ul"; + return ( + + {items.map((item, index) => ( +
  • + {item} +
  • + ))} +
    + ); +} diff --git a/makima/frontend/src/components/files/CliInput.tsx b/makima/frontend/src/components/files/CliInput.tsx index ff2b0a4..47e7616 100644 --- a/makima/frontend/src/components/files/CliInput.tsx +++ b/makima/frontend/src/components/files/CliInput.tsx @@ -8,10 +8,15 @@ import { type UserAnswer, } from "../../lib/api"; import { SimpleMarkdown } from "../SimpleMarkdown"; +import type { FocusedElement } from "./FileDetail"; interface CliInputProps { fileId: string; onUpdate: (body: BodyElement[], summary: string | null) => void; + focusedElement?: FocusedElement | null; + onClearFocus?: () => void; + suggestedPrompt?: string | null; + onClearSuggestedPrompt?: () => void; } interface Message { @@ -28,7 +33,7 @@ const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [ { value: "groq", label: "Groq Kimi" }, ]; -export function CliInput({ fileId, onUpdate }: CliInputProps) { +export function CliInput({ fileId, onUpdate, focusedElement, onClearFocus, suggestedPrompt, onClearSuggestedPrompt }: CliInputProps) { const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [messages, setMessages] = useState([]); @@ -53,6 +58,21 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { } }, [messages]); + // Auto-focus input when an element is focused + useEffect(() => { + if (focusedElement && inputRef.current) { + inputRef.current.focus(); + } + }, [focusedElement]); + + // Handle suggested prompt from generate actions + useEffect(() => { + if (suggestedPrompt) { + setInput(suggestedPrompt); + onClearSuggestedPrompt?.(); + } + }, [suggestedPrompt, onClearSuggestedPrompt]); + const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault(); @@ -73,7 +93,13 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { try { // Send request with conversation history for context - const response = await chatWithFile(fileId, userMessage, model, conversationHistory); + const response = await chatWithFile( + fileId, + userMessage, + model, + conversationHistory, + focusedElement?.index + ); // Add assistant response const assistantMsgId = (Date.now() + 1).toString(); @@ -128,7 +154,7 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { inputRef.current?.focus(); } }, - [input, loading, fileId, model, onUpdate, conversationHistory] + [input, loading, fileId, model, onUpdate, conversationHistory, focusedElement] ); // Handle option selection for a question @@ -206,7 +232,13 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { try { // Send answers as the next message - const response = await chatWithFile(fileId, answerText, model, conversationHistory); + const response = await chatWithFile( + fileId, + answerText, + model, + conversationHistory, + focusedElement?.index + ); // Add assistant response const assistantMsgId = (Date.now() + 1).toString(); @@ -258,7 +290,7 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { } finally { setLoading(false); } - }, [pendingQuestions, userAnswers, customInputs, loading, fileId, model, conversationHistory, onUpdate]); + }, [pendingQuestions, userAnswers, customInputs, loading, fileId, model, conversationHistory, onUpdate, focusedElement]); // Cancel answering questions const handleCancelQuestions = useCallback(() => { @@ -397,6 +429,22 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { ))} + + {/* Focus Badge */} + {focusedElement && ( + + )} + > void; + onFocus: (index: number) => void; + onDelete: (index: number) => void; + onDuplicate: (index: number) => void; + onConvert: (index: number, toType: string) => void; + onGenerate: (index: number, action: string) => void; + onCreateTask: (index: number, selectedText?: string) => void; +} + +export function ElementContextMenu({ + x, + y, + element, + elementIndex, + selectedText, + onClose, + onFocus, + onDelete, + onDuplicate, + onConvert, + onGenerate, + onCreateTask, +}: ElementContextMenuProps) { + const menuRef = useRef(null); + const [activeSubmenu, setActiveSubmenu] = useState<"generate" | "convert" | null>(null); + + // Close on click outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose]); + + // Adjust position if menu would overflow viewport + useEffect(() => { + if (menuRef.current) { + const rect = menuRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (rect.right > viewportWidth) { + menuRef.current.style.left = `${x - rect.width}px`; + } + if (rect.bottom > viewportHeight) { + menuRef.current.style.top = `${y - rect.height}px`; + } + } + }, [x, y]); + + const getElementTypeLabel = () => { + switch (element.type) { + case "heading": + return `Heading ${element.level}`; + case "paragraph": + return "Paragraph"; + case "code": + return element.language ? `Code (${element.language})` : "Code"; + case "list": + return element.ordered ? "Ordered List" : "Bullet List"; + case "chart": + return `Chart (${element.chartType})`; + case "image": + return "Image"; + default: + return "Element"; + } + }; + + const menuItemClass = + "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2"; + const submenuTriggerClass = + "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center justify-between"; + const dividerClass = "border-t border-[rgba(117,170,252,0.2)] my-1"; + + return ( +
    + {/* Header showing element type */} +
    + {getElementTypeLabel()} [{elementIndex}] +
    + + {/* Focus action */} + + + {/* Create task action */} + + +
    + + {/* Generate submenu */} +
    setActiveSubmenu("generate")} + onMouseLeave={() => setActiveSubmenu(null)} + > + + + {activeSubmenu === "generate" && ( +
    + + + +
    + )} +
    + + {/* Convert submenu */} +
    setActiveSubmenu("convert")} + onMouseLeave={() => setActiveSubmenu(null)} + > + + + {activeSubmenu === "convert" && ( +
    + {element.type !== "paragraph" && ( + + )} + {element.type !== "list" && ( + <> + + + + )} + {element.type !== "code" && ( + + )} + {element.type !== "heading" && ( + <> +
    + {[1, 2, 3, 4, 5, 6].map((level) => ( + + ))} + + )} +
    + )} +
    + +
    + + {/* Duplicate */} + + + {/* Delete */} + +
    + ); +} diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx index c7b716a..60458e9 100644 --- a/makima/frontend/src/components/files/FileDetail.tsx +++ b/makima/frontend/src/components/files/FileDetail.tsx @@ -3,6 +3,12 @@ import type { FileDetail as FileDetailType, FileVersionSummary, FileVersion } fr import { BodyRenderer } from "./BodyRenderer"; import { VersionHistoryDropdown } from "./VersionHistoryDropdown"; +export interface FocusedElement { + index: number; + type: string; + preview: string; +} + interface FileDetailProps { file: FileDetailType; loading: boolean; @@ -11,9 +17,17 @@ interface FileDetailProps { onDelete: (id: string) => void; onBodyElementUpdate?: (index: number, element: import("../../lib/api").BodyElement) => void; onBodyReorder?: (fromIndex: number, toIndex: number) => void; + onBodyElementDelete?: (index: number) => void; + onBodyElementDuplicate?: (index: number) => void; onEditingChange?: (isEditing: boolean) => void; hasPendingRemoteUpdate?: boolean; onOverwrite?: () => void; + // Focus element props + focusedElement?: FocusedElement | null; + onFocusElement?: (element: FocusedElement | null) => void; + onGenerateFromElement?: (index: number, action: string) => void; + onConvertElement?: (index: number, toType: string) => void; + onCreateTaskFromElement?: (index: number, selectedText?: string) => void; // Version history props versions?: FileVersionSummary[]; versionsLoading?: boolean; @@ -33,9 +47,16 @@ export function FileDetail({ onDelete, onBodyElementUpdate, onBodyReorder, + onBodyElementDelete, + onBodyElementDuplicate, onEditingChange, hasPendingRemoteUpdate, onOverwrite, + focusedElement: _focusedElement, + onFocusElement, + onGenerateFromElement, + onConvertElement, + onCreateTaskFromElement, versions = [], versionsLoading = false, selectedVersion = null, @@ -50,6 +71,38 @@ export function FileDetail({ const [description, setDescription] = useState(file.description || ""); const [transcriptExpanded, setTranscriptExpanded] = useState(false); + // Helper to get element preview text + const getElementPreview = (index: number): string => { + const element = file.body[index]; + if (!element) return ""; + switch (element.type) { + case "heading": + case "paragraph": + return element.text.slice(0, 50) + (element.text.length > 50 ? "..." : ""); + case "code": + return element.content.slice(0, 50) + (element.content.length > 50 ? "..." : ""); + case "list": + return element.items[0]?.slice(0, 40) + (element.items.length > 1 ? ` (+${element.items.length - 1} more)` : ""); + case "chart": + return element.title || `${element.chartType} chart`; + case "image": + return element.alt || element.caption || "Image"; + default: + return "Element"; + } + }; + + // Handler for focus action from context menu + const handleFocusElement = (index: number) => { + const element = file.body[index]; + if (!element || !onFocusElement) return; + onFocusElement({ + index, + type: element.type, + preview: getElementPreview(index), + }); + }; + // Update local state when file changes useEffect(() => { setName(file.name); @@ -192,6 +245,12 @@ export function FileDetail({ onEditingChange={onEditingChange} hasPendingRemoteUpdate={hasPendingRemoteUpdate} onOverwrite={onOverwrite} + onFocusElement={handleFocusElement} + onDeleteElement={onBodyElementDelete} + onDuplicateElement={onBodyElementDuplicate} + onConvertElement={onConvertElement} + onGenerateFromElement={onGenerateFromElement} + onCreateTaskFromElement={onCreateTaskFromElement} />
    diff --git a/makima/frontend/src/components/files/FileList.tsx b/makima/frontend/src/components/files/FileList.tsx index a859aa1..c537846 100644 --- a/makima/frontend/src/components/files/FileList.tsx +++ b/makima/frontend/src/components/files/FileList.tsx @@ -1,4 +1,5 @@ -import type { FileSummary } from "../../lib/api"; +import { useRef } from "react"; +import type { FileSummary, BodyElement } from "../../lib/api"; interface FileListProps { files: FileSummary[]; @@ -6,6 +7,154 @@ interface FileListProps { onSelect: (id: string) => void; onDelete: (id: string) => void; onCreate: () => void; + onUploadMarkdown?: (name: string, body: BodyElement[]) => void; +} + +/** + * Parse markdown text into BodyElements. + * Converts image embeds to links instead of images. + */ +function parseMarkdown(markdown: string): BodyElement[] { + const elements: BodyElement[] = []; + const lines = markdown.split('\n'); + let currentParagraph: string[] = []; + let inCodeBlock = false; + let codeBlockLanguage: string | undefined; + let codeBlockContent: string[] = []; + let currentList: { ordered: boolean; items: string[] } | null = null; + + const flushParagraph = () => { + if (currentParagraph.length > 0) { + const text = currentParagraph.join('\n').trim(); + if (text) { + elements.push({ type: "paragraph", text }); + } + currentParagraph = []; + } + }; + + const flushCodeBlock = () => { + if (codeBlockContent.length > 0 || inCodeBlock) { + elements.push({ + type: "code", + language: codeBlockLanguage || undefined, + content: codeBlockContent.join('\n'), + }); + codeBlockContent = []; + codeBlockLanguage = undefined; + } + }; + + const flushList = () => { + if (currentList && currentList.items.length > 0) { + elements.push({ + type: "list", + ordered: currentList.ordered, + items: currentList.items, + }); + currentList = null; + } + }; + + // Convert image syntax ![alt](url) to link syntax [alt](url) or [image](url) + const convertImagesToLinks = (text: string): string => { + return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => { + const linkText = alt || 'image'; + return `[${linkText}](${url})`; + }); + }; + + for (const rawLine of lines) { + // Check for code block fence (``` or ~~~) + const codeFenceMatch = rawLine.match(/^(`{3,}|~{3,})(\w*)?$/); + if (codeFenceMatch) { + if (!inCodeBlock) { + // Starting a code block + flushParagraph(); + flushList(); + inCodeBlock = true; + codeBlockLanguage = codeFenceMatch[2] || undefined; + codeBlockContent = []; + } else { + // Ending a code block + flushCodeBlock(); + inCodeBlock = false; + } + continue; + } + + // If inside a code block, add line as-is + if (inCodeBlock) { + codeBlockContent.push(rawLine); + continue; + } + + // Convert images to links in all lines + const line = convertImagesToLinks(rawLine); + + // Check for headings + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + flushParagraph(); + flushList(); + const level = headingMatch[1].length; + const text = headingMatch[2].trim(); + elements.push({ type: "heading", level, text }); + continue; + } + + // Check for unordered list items (-, *, +) + const unorderedMatch = line.match(/^[\s]*[-*+]\s+(.+)$/); + if (unorderedMatch) { + flushParagraph(); + const itemText = unorderedMatch[1].trim(); + if (currentList && currentList.ordered) { + // Switch from ordered to unordered + flushList(); + } + if (!currentList) { + currentList = { ordered: false, items: [] }; + } + currentList.items.push(itemText); + continue; + } + + // Check for ordered list items (1. 2. etc) + const orderedMatch = line.match(/^[\s]*\d+\.\s+(.+)$/); + if (orderedMatch) { + flushParagraph(); + const itemText = orderedMatch[1].trim(); + if (currentList && !currentList.ordered) { + // Switch from unordered to ordered + flushList(); + } + if (!currentList) { + currentList = { ordered: true, items: [] }; + } + currentList.items.push(itemText); + continue; + } + + // Empty line - flush everything + if (line.trim() === '') { + flushParagraph(); + flushList(); + continue; + } + + // Regular text - flush list first, then add to paragraph + flushList(); + currentParagraph.push(line); + } + + // Flush any remaining content + if (inCodeBlock) { + flushCodeBlock(); + } + flushParagraph(); + flushList(); + + return elements; } function formatDuration(seconds: number | null): string { @@ -32,7 +181,32 @@ export function FileList({ onSelect, onDelete, onCreate, + onUploadMarkdown, }: FileListProps) { + const fileInputRef = useRef(null); + + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file || !onUploadMarkdown) return; + + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result as string; + if (content) { + const body = parseMarkdown(content); + // Use filename without extension as the name + const name = file.name.replace(/\.md$/i, '') || 'Imported Document'; + onUploadMarkdown(name, body); + } + }; + reader.readAsText(file); + + // Reset input so the same file can be uploaded again + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + if (loading) { return (
    @@ -47,12 +221,31 @@ export function FileList({
    FILES//
    - +
    + {onUploadMarkdown && ( + <> + + + + )} + +
    diff --git a/makima/frontend/src/components/mesh/DirectoryInput.tsx b/makima/frontend/src/components/mesh/DirectoryInput.tsx new file mode 100644 index 0000000..e2e331e --- /dev/null +++ b/makima/frontend/src/components/mesh/DirectoryInput.tsx @@ -0,0 +1,220 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import type { DaemonDirectory } from "../../lib/api"; + +interface DirectoryInputProps { + value: string; + onChange: (value: string) => void; + suggestions: DaemonDirectory[]; + placeholder?: string; + /** Repository URL to extract repo name for home directory suggestions */ + repoUrl?: string | null; + className?: string; + disabled?: boolean; +} + +/** Extract repository name from URL */ +function extractRepoName(url: string | null | undefined): string | null { + if (!url) return null; + + // Handle various URL formats: + // https://github.com/user/repo.git -> repo + // https://github.com/user/repo -> repo + // git@github.com:user/repo.git -> repo + // /path/to/local/repo -> repo + + let name = url; + + // Remove trailing .git + if (name.endsWith(".git")) { + name = name.slice(0, -4); + } + + // Remove trailing slash + if (name.endsWith("/")) { + name = name.slice(0, -1); + } + + // Get the last path segment + const lastSlash = name.lastIndexOf("/"); + if (lastSlash !== -1) { + name = name.slice(lastSlash + 1); + } + + // Handle git@host:user/repo format + const colonIndex = name.lastIndexOf(":"); + if (colonIndex !== -1) { + const afterColon = name.slice(colonIndex + 1); + const slashIndex = afterColon.lastIndexOf("/"); + if (slashIndex !== -1) { + name = afterColon.slice(slashIndex + 1); + } else { + name = afterColon; + } + } + + return name || null; +} + +export function DirectoryInput({ + value, + onChange, + suggestions, + placeholder = "/path/to/directory", + repoUrl, + className = "", + disabled = false, +}: DirectoryInputProps) { + const [showSuggestions, setShowSuggestions] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + + // Extract repo name for home directory suggestions + const repoName = extractRepoName(repoUrl); + + // Process suggestions to add repo name to home directory + const processedSuggestions = suggestions.map((dir) => { + if (dir.directoryType === "home" && repoName) { + return { + ...dir, + path: `${dir.path}/${repoName}`, + label: `${dir.label} (${repoName})`, + }; + } + return dir; + }); + + // Filter suggestions based on current input + const filteredSuggestions = processedSuggestions.filter((dir) => { + if (!value) return true; + const lowerValue = value.toLowerCase(); + return ( + dir.path.toLowerCase().includes(lowerValue) || + dir.label.toLowerCase().includes(lowerValue) + ); + }); + + // Handle clicking outside to close dropdown + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) && + inputRef.current && + !inputRef.current.contains(e.target as Node) + ) { + setShowSuggestions(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleFocus = useCallback(() => { + setShowSuggestions(true); + setHighlightedIndex(-1); + }, []); + + const handleBlur = useCallback(() => { + // Delay hiding to allow click on suggestion + setTimeout(() => { + setShowSuggestions(false); + }, 150); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!showSuggestions || filteredSuggestions.length === 0) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setHighlightedIndex((prev) => + prev < filteredSuggestions.length - 1 ? prev + 1 : 0 + ); + break; + case "ArrowUp": + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredSuggestions.length - 1 + ); + break; + case "Enter": + e.preventDefault(); + if (highlightedIndex >= 0 && highlightedIndex < filteredSuggestions.length) { + onChange(filteredSuggestions[highlightedIndex].path); + setShowSuggestions(false); + } + break; + case "Escape": + setShowSuggestions(false); + break; + } + }, + [showSuggestions, filteredSuggestions, highlightedIndex, onChange] + ); + + const handleSelectSuggestion = useCallback( + (path: string) => { + onChange(path); + setShowSuggestions(false); + inputRef.current?.focus(); + }, + [onChange] + ); + + return ( +
    + onChange(e.target.value)} + onFocus={handleFocus} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3] disabled:opacity-50" + /> + + {/* Suggestions dropdown */} + {showSuggestions && filteredSuggestions.length > 0 && ( +
    + {filteredSuggestions.map((dir, index) => ( + + ))} +
    + )} +
    + ); +} diff --git a/makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx b/makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx new file mode 100644 index 0000000..3621b08 --- /dev/null +++ b/makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx @@ -0,0 +1,262 @@ +import { useState, useCallback, useEffect } from "react"; +import type { TaskWithSubtasks, TaskStatus } from "../../lib/api"; +import { getTask, updateTask } from "../../lib/api"; + +interface InlineSubtaskEditorProps { + subtaskId: string; + onClose: () => void; + onUpdated: () => void; + onNavigate?: (taskId: string) => void; +} + +function getStatusColor(status: TaskStatus): string { + switch (status) { + case "pending": + return "text-[#9bc3ff]"; + case "running": + return "text-green-400"; + case "paused": + return "text-yellow-400"; + case "blocked": + return "text-orange-400"; + case "done": + return "text-emerald-400"; + case "failed": + return "text-red-400"; + case "merged": + return "text-purple-400"; + default: + return "text-[#9bc3ff]"; + } +} + +function getStatusBgColor(status: TaskStatus): string { + switch (status) { + case "pending": + return "bg-[rgba(117,170,252,0.1)]"; + case "running": + return "bg-green-400/10"; + case "paused": + return "bg-yellow-400/10"; + case "blocked": + return "bg-orange-400/10"; + case "done": + return "bg-emerald-400/10"; + case "failed": + return "bg-red-400/10"; + case "merged": + return "bg-purple-400/10"; + default: + return "bg-[rgba(117,170,252,0.1)]"; + } +} + +export function InlineSubtaskEditor({ + subtaskId, + onClose, + onUpdated, + onNavigate, +}: InlineSubtaskEditorProps) { + const [subtask, setSubtask] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(""); + const [editDescription, setEditDescription] = useState(""); + const [editPlan, setEditPlan] = useState(""); + + // Load subtask details + useEffect(() => { + setLoading(true); + getTask(subtaskId) + .then((task) => { + setSubtask(task); + setEditName(task.name); + setEditDescription(task.description || ""); + setEditPlan(task.plan); + }) + .catch((err) => { + console.error("Failed to load subtask:", err); + }) + .finally(() => { + setLoading(false); + }); + }, [subtaskId]); + + const handleSave = useCallback(async () => { + if (!subtask || saving) return; + setSaving(true); + try { + await updateTask(subtaskId, { + name: editName, + description: editDescription || undefined, + plan: editPlan, + version: subtask.version, + }); + // Refresh subtask + const updated = await getTask(subtaskId); + setSubtask(updated); + setIsEditing(false); + onUpdated(); + } catch (err) { + console.error("Failed to save subtask:", err); + } finally { + setSaving(false); + } + }, [subtask, subtaskId, editName, editDescription, editPlan, saving, onUpdated]); + + const handleCancel = useCallback(() => { + if (subtask) { + setEditName(subtask.name); + setEditDescription(subtask.description || ""); + setEditPlan(subtask.plan); + } + setIsEditing(false); + }, [subtask]); + + if (loading) { + return ( +
    +
    Loading subtask...
    +
    + ); + } + + if (!subtask) { + return ( +
    +
    Failed to load subtask
    +
    + ); + } + + return ( +
    + {/* Header */} +
    +
    + + + {subtask.status} + + {onNavigate && ( + + )} +
    +
    + {isEditing ? ( + <> + + + + ) : ( + + )} +
    +
    + + {/* Content */} +
    + {/* Name */} + {isEditing ? ( + setEditName(e.target.value)} + className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-2 py-1 outline-none focus:border-[#3f6fb3]" + placeholder="Subtask name" + /> + ) : ( +
    {subtask.name}
    + )} + + {/* Description */} + {isEditing ? ( +