diff --git a/Dockerfile.optimized b/Dockerfile.optimized new file mode 100644 index 0000000000000000000000000000000000000000..9f2ff15f8fbe7dd159d8864d2bd15007ca092fce --- /dev/null +++ b/Dockerfile.optimized @@ -0,0 +1,310 @@ +# ───────────────────────────────────────────────────────────── +# Multi-stage Dockerfile for Elixir Phoenix Applications +# Highly optimized for Debian Bullseye with aggressive caching +# Supports lazy_html, lexbor, vix, and other native dependencies +# ───────────────────────────────────────────────────────────── + +# Build arguments - pinned versions for reproducible builds +ARG ELIXIR_VERSION=1.18.4 +ARG OTP_VERSION=28.0.4 +ARG DEBIAN_VERSION=bullseye-20250908-slim +ARG NODE_VERSION=20 + +ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" +ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" +ARG NODE_IMAGE="node:${NODE_VERSION}-${DEBIAN_VERSION}" + +# ═════════════════════════════════════════════════════════════ +# STAGE 0: Base system dependencies layer (aggressive caching) +# ═════════════════════════════════════════════════════════════ +FROM ${BUILDER_IMAGE} as base-deps + +# Install system dependencies in optimized layers for maximum caching +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Core build tools layer +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + make \ + gcc \ + g++ \ + pkg-config \ + autoconf \ + libtool \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Development libraries layer +RUN apt-get update && apt-get install -y --no-install-recommends \ + libc6-dev \ + libssl-dev \ + libncurses5-dev \ + zlib1g-dev \ + libxml2-dev \ + libxslt1-dev \ + libcurl4-openssl-dev \ + libpcre3-dev \ + libffi-dev \ + libglib2.0-dev \ + libexpat1-dev \ + libmagic-dev \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Image processing libraries layer (vix dependencies) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libjpeg-dev \ + libpng-dev \ + libtiff-dev \ + libwebp-dev \ + libheif-dev \ + liborc-0.4-dev \ + libvips-dev \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Set build environment +ENV MIX_ENV="prod" +ENV ERL_FLAGS="+JPperf true" + +# Install hex and rebar once and cache +RUN mix local.hex --force && \ + mix local.rebar --force + +# ═════════════════════════════════════════════════════════════ +# STAGE 1: Node.js dependencies (parallel caching) +# ═════════════════════════════════════════════════════════════ +FROM ${NODE_IMAGE} as node-deps + +WORKDIR /app + +# Copy and install Node.js dependencies first (better caching) +COPY assets/package*.json assets/ +RUN cd assets && npm ci --only=production --no-audit --no-fund + +# ═════════════════════════════════════════════════════════════ +# STAGE 2: Elixir dependencies (parallel caching) +# ═════════════════════════════════════════════════════════════ +FROM base-deps as elixir-deps + +WORKDIR /app + +# Copy dependency files for better layer caching +COPY mix.exs mix.lock ./ + +# Download dependencies first (cached layer) +RUN mix deps.get --only $MIX_ENV + +# Create minimal config structure +RUN mkdir -p config + +# Copy config files needed for compilation (separate layer) +COPY config/config.exs config/${MIX_ENV}.exs config/runtime.exs config/ + +# Compile dependencies in separate layer for better caching +RUN mix deps.compile + +# ═════════════════════════════════════════════════════════════ +# STAGE 3: Assets compilation (using cached node modules) +# ═════════════════════════════════════════════════════════════ +FROM elixir-deps as assets-builder + +# Copy Node.js dependencies from parallel stage +COPY --from=node-deps /app/assets/node_modules assets/node_modules + +# Copy assets source +COPY assets assets +COPY priv priv + +# Build assets with Node.js from node-deps stage +COPY --from=node-deps /usr/local/bin/node /usr/local/bin/node +COPY --from=node-deps /usr/local/bin/npm /usr/local/bin/npm + +# Compile assets +RUN mix assets.setup 2>/dev/null || true +RUN mix assets.deploy + +# ═════════════════════════════════════════════════════════════ +# STAGE 4: Application compilation (using pre-compiled deps) +# ═════════════════════════════════════════════════════════════ +FROM elixir-deps as app-builder + +# Copy compiled assets from assets stage +COPY --from=assets-builder /app/priv/static priv/static + +# Copy application source +COPY lib lib +COPY config config + +# Copy additional files needed for compilation +COPY .formatter.exs .credo.exs ./ + +# Compile application (deps already compiled in previous stage) +RUN mix compile --force + +# Build release with optimizations +RUN mix release --overwrite + +# ═════════════════════════════════════════════════════════════ +# STAGE 5: Runtime image (minimal and optimized) +# ═════════════════════════════════════════════════════════════ +FROM ${RUNNER_IMAGE} as runtime + +# Install only runtime dependencies in minimal layers +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + openssl \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Runtime libraries +RUN apt-get update && apt-get install -y --no-install-recommends \ + libstdc++6 \ + libncurses5 \ + zlib1g \ + libxml2 \ + libxslt1.1 \ + libpcre3 \ + tzdata \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Image processing runtime libraries (for vix) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libvips42 \ + libheif1 \ + libwebp6 \ + libjpeg62-turbo \ + libpng16-16 \ + libtiff5 \ + libffi6 \ + libglib2.0-0 \ + libexpat1 \ + libmagic1 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Create app user +RUN groupadd --gid 1000 app && \ + useradd --uid 1000 --gid app --shell /bin/bash --create-home app + +# Create app directory +WORKDIR /app + +# Set runtime environment +ENV MIX_ENV="prod" +ENV PHX_SERVER="true" +ENV ERL_FLAGS="+JPperf true" + +# Copy release from builder stage +COPY --from=app-builder --chown=app:app /app/_build/prod/rel/sig ./ + +# Switch to app user +USER app + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:4000/health || exit 1 + +# Expose port +EXPOSE 4000 + +# Start the application +CMD ["./bin/sig", "start"] + +# ═════════════════════════════════════════════════════════════ +# STAGE 6: Development image (cached layers reuse) +# ═════════════════════════════════════════════════════════════ +FROM base-deps as development + +# Add development-specific tools +RUN apt-get update && apt-get install -y --no-install-recommends \ + inotify-tools \ + postgresql-client \ + python3 \ + python3-pip \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Add Node.js for development +COPY --from=node-deps /usr/local/bin/node /usr/local/bin/node +COPY --from=node-deps /usr/local/bin/npm /usr/local/bin/npm +COPY --from=node-deps /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm + +# Set development environment +ENV MIX_ENV="dev" +ENV ERL_FLAGS="+JPperf true" + +WORKDIR /app + +# Copy all files for development +COPY . . + +# Install all dependencies (including dev/test) +RUN mix deps.get + +# Compile application +RUN mix compile + +# Install assets +RUN mix assets.setup 2>/dev/null || true + +# Expose ports for development +EXPOSE 4000 4001 + +# Default command for development +CMD ["mix", "phx.server"] + +# ═════════════════════════════════════════════════════════════ +# STAGE 7: CI/Test image (optimized for testing) +# ═════════════════════════════════════════════════════════════ +FROM base-deps as ci + +# Add CI-specific tools +RUN apt-get update && apt-get install -y --no-install-recommends \ + postgresql-client \ + python3 \ + python3-pip \ + bash \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Set test environment +ENV MIX_ENV="test" +ENV ERL_FLAGS="+JPperf true" + +WORKDIR /app + +# Copy dependency files first for better caching +COPY mix.exs mix.lock ./ +RUN mix deps.get + +# Copy config +COPY config config + +# Copy source and test files +COPY lib lib +COPY test test +COPY priv priv +COPY assets assets + +# Copy configuration files +COPY .formatter.exs .credo.exs .sobelow-conf ./ + +# Compile dependencies and application +RUN mix deps.compile +RUN mix compile + +# Setup assets for testing +RUN mix assets.setup 2>/dev/null || true + +# Default command for CI +CMD ["mix", "test"] \ No newline at end of file diff --git a/config/runtime.exs b/config/runtime.exs index 8bda56a4097dc3d582009c591c966f6f451f91cf..ec4ba4bfa580c0cf9c77b8ce5a951ceb36403f92 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -55,14 +55,15 @@ if config_env() == :prod do config :sig, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") + # Configure IP binding based on Fly.io requirements + # Fly.io requires binding to 0.0.0.0 (IPv4) for HTTP traffic + bind_ip = if System.get_env("FLY_APP_NAME"), do: {0, 0, 0, 0}, else: {127, 0, 0, 1} + config :sig, SigWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], http: [ - # Enable IPv6 and bind on all interfaces. - # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. - # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 - # for details about using IPv6 vs IPv4 and loopback vs public addresses. - ip: {0, 0, 0, 0, 0, 0, 0, 0}, + # Bind on all interfaces for Fly.io deployment + ip: bind_ip, port: port ], secret_key_base: secret_key_base diff --git a/lib/blackboard.ex b/lib/blackboard.ex index 15b8147efb79fe858e98480df51c97d5342a5a64..f0f90464382310201ac7f44e2b7b4a7e049a90fc 100644 --- a/lib/blackboard.ex +++ b/lib/blackboard.ex @@ -252,6 +252,7 @@ defmodule Blackboard do ] case find_messages(filters, opts) do + {:ok, results} when is_list(results) -> Enum.map(results, & &1.value) {:error, _, _} -> [] results when is_list(results) -> Enum.map(results, & &1.value) end diff --git a/lib/sig/chat.ex b/lib/sig/chat.ex index f92759eba36936c9a30befb4a8e1f15b939b314b..5eef71125f568e6cc019aae0c9001a3ab58651c5 100644 --- a/lib/sig/chat.ex +++ b/lib/sig/chat.ex @@ -221,6 +221,8 @@ defmodule Sig.Chat do defp determine_message_type(content, opts) do cond do + # Explicit type specified in options (for IRC commands like /me, system messages, etc.) + Keyword.get(opts, :type) -> Keyword.get(opts, :type) Keyword.get(opts, :llm_backend) -> :llm_response String.starts_with?(content, "/") -> :command String.contains?(content, "@") -> :mention diff --git a/lib/sig_web/components/navigation.ex b/lib/sig_web/components/navigation.ex index 85e641b65ec69d53e5bb5a3b789d39019ae5a96d..53de7fc0b7ea5ee75628e44db578ffe536567466 100644 --- a/lib/sig_web/components/navigation.ex +++ b/lib/sig_web/components/navigation.ex @@ -296,7 +296,7 @@ defmodule SigWeb.Components.Navigation do
D
- + msgs + {:error, _} -> [] + msgs when is_list(msgs) -> msgs + _ -> [] + end # Apply text search if specified messages = @@ -231,7 +236,13 @@ defmodule SigWeb.BlackboardLive do end defp load_agent_status(socket) do - agents = Blackboard.get_active_agents() + agents = + case Blackboard.get_active_agents() do + {:ok, agents_list} -> agents_list + {:error, _} -> [] + agents_list when is_list(agents_list) -> agents_list + _ -> [] + end # Enhance with additional metrics enhanced_agents = diff --git a/lib/sig_web/live/chat_live.ex b/lib/sig_web/live/chat_live.ex index 7603d35ed216d983ebe9b1f6e86b4f95b16a0950..c25412b2fc777fb222f6cdaeca270c37f285c2d8 100644 --- a/lib/sig_web/live/chat_live.ex +++ b/lib/sig_web/live/chat_live.ex @@ -82,27 +82,41 @@ defmodule SigWeb.ChatLive do current_channel = socket.assigns.current_channel user_name = socket.assigns.user_name - # Check if this is an LLM-dedicated channel - case get_channel_llm_backend(current_channel) do - nil -> - # Regular channel - just send the message - Chat.send_message(user_name, content, current_channel) - - llm_backend -> - # LLM channel - send to LLM for a response - spawn(fn -> - case Chat.chat_with_llm(content, current_channel, llm_backend, user_name) do - {:ok, _response} -> - :ok - - {:error, reason} -> - IO.puts("LLM chat failed: #{inspect(reason)}") + # Check if this is an IRC command + socket = + case handle_irc_command(content, socket) do + {:command, updated_socket} -> + updated_socket + + :not_command -> + # Check if this is an LLM-dedicated channel + case get_channel_llm_backend(current_channel) do + nil -> + # Regular channel - just send the message + Chat.send_message(user_name, content, current_channel) + socket + + llm_backend -> + # LLM channel - send to LLM for a response + spawn(fn -> + case Chat.chat_with_llm(content, current_channel, llm_backend, user_name) do + {:ok, _response} -> + :ok + + {:error, reason} -> + IO.puts("LLM chat failed: #{inspect(reason)}") + end + end) + + socket end - end) - end + end + + assign(socket, :message_input, "") + else + socket end - socket = assign(socket, :message_input, "") {:noreply, socket} end @@ -170,6 +184,158 @@ defmodule SigWeb.ChatLive do end # Helper function to determine LLM backend for a channel + # IRC Command Handling + + defp handle_irc_command("/" <> command_str, socket) do + [command | args] = String.split(command_str, " ", trim: true) + + case String.downcase(command) do + "help" -> + help_message = """ + **Available IRC Commands:** + + `/help` - Show this help message + `/join ` - Join or create a channel (e.g., `/join debug`) + `/nick ` - Change your nickname + `/me ` - Send an action message (e.g., `/me waves hello`) + `/agents` - List all online agents + `/clear` - Clear chat history (local only) + `/channels` - List available channels + """ + + socket = put_flash(socket, :info, help_message) + {:command, socket} + + "join" -> + case args do + [channel_name] -> + clean_channel = "#" <> String.replace(channel_name, "#", "") + clean_path = String.replace(clean_channel, "#", "") + socket = push_patch(socket, to: ~p"/chat/rooms/#{clean_path}") + socket = put_flash(socket, :info, "Joined #{clean_channel}") + {:command, socket} + + _ -> + socket = put_flash(socket, :error, "Usage: /join ") + {:command, socket} + end + + "nick" -> + case args do + [new_nick] -> + # Update the user's nickname + old_name = socket.assigns.user_name + # Remove old user + Chat.agent_status(old_name, :offline) + # Add new user + Chat.agent_status(new_nick, :online) + + # Send system message about nick change + Chat.send_message( + "system", + "#{old_name} is now known as #{new_nick}", + socket.assigns.current_channel, + type: "system" + ) + + socket = + socket + |> assign(:user_name, new_nick) + |> put_flash(:info, "Nickname changed to #{new_nick}") + + {:command, socket} + + _ -> + socket = put_flash(socket, :error, "Usage: /nick ") + {:command, socket} + end + + "me" -> + case args do + [] -> + socket = put_flash(socket, :error, "Usage: /me ") + {:command, socket} + + action_words -> + action_text = Enum.join(action_words, " ") + user_name = socket.assigns.user_name + current_channel = socket.assigns.current_channel + + # Send as action message + Chat.send_message(user_name, action_text, current_channel, type: "action") + {:command, socket} + end + + "agents" -> + case Chat.list_agents() do + {:ok, agents} -> + agent_list = + agents + |> Enum.map(& &1.name) + |> Enum.join(", ") + + message = + if agent_list != "" do + "**Online Agents:** #{agent_list}" + else + "No agents currently online." + end + + socket = put_flash(socket, :info, message) + {:command, socket} + + _ -> + socket = put_flash(socket, :error, "Unable to retrieve agent list") + {:command, socket} + end + + "clear" -> + # Clear local chat history - this would need to be implemented in the UI + socket = + socket + |> assign(:messages, []) + |> put_flash(:info, "Chat history cleared locally") + + {:command, socket} + + "channels" -> + case Chat.list_channels() do + {:ok, channels} -> + channel_list = Enum.join(channels, ", ") + + message = + if channel_list != "" do + "**Available Channels:** #{channel_list}" + else + "No active channels found." + end + + socket = put_flash(socket, :info, message) + {:command, socket} + + _ -> + socket = put_flash(socket, :error, "Unable to retrieve channel list") + {:command, socket} + end + + unknown -> + socket = + put_flash( + socket, + :error, + "Unknown command: /#{unknown}. Type /help for available commands." + ) + + {:command, socket} + end + end + + defp handle_irc_command(_content, _socket) do + :not_command + end + + # Channel Helper Functions + defp get_channel_llm_backend(channel) do case channel do "#claude" -> :anthropic diff --git a/lib/sig_web/live/dev_task_live.ex b/lib/sig_web/live/dev_task_live.ex new file mode 100644 index 0000000000000000000000000000000000000000..5715127ce08b2c670d175d0fadddf1274305ed29 --- /dev/null +++ b/lib/sig_web/live/dev_task_live.ex @@ -0,0 +1,584 @@ +defmodule SigWeb.DevTaskLive do + @moduledoc """ + LiveView for individual Mix task execution with streaming output. + + Provides dedicated pages for running individual Mix tasks with: + - Real-time streaming output + - Progress tracking + - Task termination capabilities + - Historical task runs + - Direct deep-linking to specific tasks + + ## Features + + - **Isolated Execution** - Each task gets its own dedicated interface + - **Streaming Output** - Real-time stdout/stderr streaming + - **Process Control** - Start, stop, and monitor task processes + - **History Tracking** - Previous runs and their results + - **Deep Linking** - Direct URLs for specific tasks + - **Multi-session** - Support multiple concurrent task instances + """ + + use SigWeb, :live_view + require Logger + + @impl true + def mount(%{"task" => task_name}, _session, socket) do + if connected?(socket) do + # Subscribe to task execution updates for this specific task + Phoenix.PubSub.subscribe(Sig.PubSub, "dev_task:#{task_name}") + end + + socket = + socket + |> assign(:page_title, "Task: #{task_name}") + |> assign(:task_name, task_name) + |> assign(:task_display_name, format_task_display_name(task_name)) + |> assign(:running_instances, %{}) + |> assign(:task_history, []) + |> assign(:task_args, "") + |> assign(:selected_action, :show) + |> load_task_metadata(task_name) + |> load_task_history(task_name) + + {:ok, socket} + end + + @impl true + def handle_params(%{"task" => task_name}, _url, socket) do + action = + if String.contains?(socket.private.connect_info.request_path, "/stream"), + do: :stream, + else: :show + + socket = + socket + |> assign(:task_name, task_name) + |> assign(:selected_action, action) + + {:noreply, socket} + end + + @impl true + def handle_event("start_task", %{"args" => args}, socket) do + task_name = socket.assigns.task_name + instance_id = generate_instance_id() + + # Parse arguments + parsed_args = if String.trim(args) == "", do: [], else: String.split(String.trim(args), " ") + + # Start task execution + case start_task_execution(task_name, parsed_args, instance_id) do + {:ok, pid} -> + instance = %{ + id: instance_id, + pid: pid, + args: parsed_args, + output: [], + status: :running, + started_at: DateTime.utc_now(), + exit_code: nil + } + + running_instances = Map.put(socket.assigns.running_instances, instance_id, instance) + + socket = + socket + |> assign(:running_instances, running_instances) + |> put_flash(:info, "Task started with ID: #{instance_id}") + + {:noreply, socket} + + {:error, reason} -> + socket = put_flash(socket, :error, "Failed to start task: #{inspect(reason)}") + {:noreply, socket} + end + end + + def handle_event("stop_task", %{"instance_id" => instance_id}, socket) do + case Map.get(socket.assigns.running_instances, instance_id) do + nil -> + {:noreply, put_flash(socket, :error, "Task instance not found")} + + instance -> + Process.exit(instance.pid, :kill) + + updated_instance = %{instance | status: :killed, exit_code: -1} + + running_instances = + Map.put(socket.assigns.running_instances, instance_id, updated_instance) + + socket = + socket + |> assign(:running_instances, running_instances) + |> put_flash(:info, "Task stopped") + + {:noreply, socket} + end + end + + def handle_event("clear_output", %{"instance_id" => instance_id}, socket) do + case Map.get(socket.assigns.running_instances, instance_id) do + nil -> + {:noreply, socket} + + instance -> + updated_instance = %{instance | output: []} + + running_instances = + Map.put(socket.assigns.running_instances, instance_id, updated_instance) + + {:noreply, assign(socket, :running_instances, running_instances)} + end + end + + def handle_event("remove_instance", %{"instance_id" => instance_id}, socket) do + running_instances = Map.delete(socket.assigns.running_instances, instance_id) + {:noreply, assign(socket, :running_instances, running_instances)} + end + + def handle_event("update_args", %{"args" => args}, socket) do + {:noreply, assign(socket, :task_args, args)} + end + + def handle_event("clear_history", _params, socket) do + # In a real implementation, this would clear from persistent storage + {:noreply, assign(socket, :task_history, [])} + end + + @impl true + def handle_info({:task_output, instance_id, output}, socket) do + case Map.get(socket.assigns.running_instances, instance_id) do + nil -> + {:noreply, socket} + + instance -> + updated_instance = %{instance | output: instance.output ++ [output]} + + running_instances = + Map.put(socket.assigns.running_instances, instance_id, updated_instance) + + {:noreply, assign(socket, :running_instances, running_instances)} + end + end + + def handle_info({:task_completed, instance_id, exit_code}, socket) do + case Map.get(socket.assigns.running_instances, instance_id) do + nil -> + {:noreply, socket} + + instance -> + completed_instance = %{ + instance + | status: if(exit_code == 0, do: :completed, else: :failed), + exit_code: exit_code, + completed_at: DateTime.utc_now() + } + + # Add to history + history_entry = %{ + id: instance_id, + task: socket.assigns.task_name, + args: instance.args, + exit_code: exit_code, + output: instance.output, + started_at: instance.started_at, + completed_at: completed_instance.completed_at, + status: completed_instance.status + } + + running_instances = + Map.put(socket.assigns.running_instances, instance_id, completed_instance) + + task_history = [history_entry | socket.assigns.task_history] |> Enum.take(20) + + flash_type = if exit_code == 0, do: :info, else: :error + flash_msg = if exit_code == 0, do: "Task completed successfully", else: "Task failed" + + socket = + socket + |> assign(:running_instances, running_instances) + |> assign(:task_history, task_history) + |> put_flash(flash_type, flash_msg) + + {:noreply, socket} + end + end + + def handle_info(_msg, socket), do: {:noreply, socket} + + @impl true + def render(assigns) do + ~H""" +
+
+ +
+
+
+ +

+ ⚙️ {@task_display_name} +

+

+ {get_task_description(@task_metadata)} +

+
+
+ <.link + navigate="/dev/tools" + class="inline-flex items-center px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors" + > + ← Back to Tools + +
+
+
+ + +
+

Task Execution

+ +
+
+ + +
+ +
+
+ + + <%= if map_size(@running_instances) > 0 do %> +
+

Active Instances

+
+ <%= for {instance_id, instance} <- @running_instances do %> +
+ +
+
+ + "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300" + + :completed -> + "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300" + + :failed -> + "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300" + + :killed -> + "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300" + end + ]}> + <%= case instance.status do %> + <% :running -> %> +
+
+ Running + <% :completed -> %> + ✅ Completed + <% :failed -> %> + ❌ Failed + <% :killed -> %> + 🛑 Killed + <% end %> +
+ +
+
+ mix {@task_name} {Enum.join(instance.args, " ")} +
+
+ Started: {Calendar.strftime(instance.started_at, "%H:%M:%S")} + <%= if instance.exit_code do %> + • Exit code: {instance.exit_code} + <% end %> +
+
+
+ +
+ <%= if instance.status == :running do %> + + <% end %> + + <%= if instance.status != :running do %> + + <% end %> +
+
+ + +
+
+ <%= if Enum.empty?(instance.output) do %> +
No output yet...
+ <% else %> + <%= for line <- instance.output do %> +
{line}
+ <% end %> + <% end %> +
+
+
+ <% end %> +
+
+ <% end %> + + + <%= if not Enum.empty?(@task_history) do %> +
+
+

Recent Runs

+ +
+ +
+ <%= for entry <- Enum.take(@task_history, 10) do %> +
+
+
+ + {if entry.exit_code == 0, do: "✅ Success", else: "❌ Failed"} + + + mix {entry.task} {Enum.join(entry.args, " ")} + +
+
+ {Calendar.strftime(entry.completed_at, "%H:%M:%S")} +
+
+ + <%= if length(entry.output) > 0 do %> +
+ + Show output ({length(entry.output)} lines) + +
+                        {Enum.join(entry.output, "\n")}
+                      
+
+ <% end %> +
+ <% end %> +
+
+ <% end %> + + +
+

Task Information

+ +
+
+
Task Name
+
{@task_name}
+
+
+
Category
+
+ {Map.get(@task_metadata, :category, "Unknown")} +
+
+
+
Description
+
+ {Map.get(@task_metadata, :description, "No description available")} +
+
+
+
Direct Link
+
+ + /dev/tasks/{@task_name} + +
+
+
+
+
+
+ """ + end + + # Private Functions + + defp format_task_display_name(task_name) do + task_name + |> String.replace("_", " ") + |> String.split(".") + |> Enum.map(&String.capitalize/1) + |> Enum.join(" ") + end + + defp load_task_metadata(socket, task_name) do + # Get task metadata from the same source as DevLive + metadata = get_task_metadata(task_name) + assign(socket, :task_metadata, metadata) + end + + defp get_task_metadata(task_name) do + # This would normally load from a database or configuration + # For now, we'll use the same task list from DevLive + tasks = [ + %{ + name: "precommit", + description: "Run pre-commit validations", + category: "Quality", + icon: "✅" + }, + %{name: "test", description: "Run the test suite", category: "Testing", icon: "🧪"}, + %{ + name: "compile", + description: "Compile with strict warnings", + category: "Build", + icon: "🏗️" + }, + %{ + name: "docs.sync", + description: "Synchronize documentation", + category: "Documentation", + icon: "🔄" + }, + %{name: "format", description: "Format Elixir source code", category: "Build", icon: "🎨"}, + %{name: "credo", description: "Run Credo static analysis", category: "Quality", icon: "🔍"}, + %{ + name: "adr.validate", + description: "Validate Architecture Decision Records", + category: "Documentation", + icon: "✅" + } + ] + + Enum.find(tasks, %{}, fn task -> task.name == task_name end) + end + + defp get_task_description(metadata) do + Map.get(metadata, :description, "Execute Mix task with real-time output streaming") + end + + defp load_task_history(socket, _task_name) do + # In a real implementation, this would load from persistent storage + # For now, we'll start with an empty history + assign(socket, :task_history, []) + end + + defp generate_instance_id do + :crypto.strong_rand_bytes(8) |> Base.encode16() |> String.downcase() + end + + defp start_task_execution(task_name, args, instance_id) do + parent = self() + + pid = + spawn_link(fn -> + run_mix_task_streaming(parent, instance_id, task_name, args) + end) + + {:ok, pid} + rescue + error -> {:error, error} + end + + defp run_mix_task_streaming(parent, instance_id, task_name, args) do + try do + # Use Port for streaming output + port = + Port.open( + {:spawn_executable, System.find_executable("mix")}, + [ + :binary, + :exit_status, + :stderr_to_stdout, + args: [task_name | args] + ] + ) + + stream_task_output(parent, instance_id, port) + rescue + error -> + send(parent, {:task_output, instance_id, "Error: #{inspect(error)}"}) + send(parent, {:task_completed, instance_id, 1}) + end + end + + defp stream_task_output(parent, instance_id, port) do + receive do + {^port, {:data, data}} -> + # Send each line separately for better UX + data + |> String.split("\n") + |> Enum.each(fn line -> + if String.trim(line) != "" do + send(parent, {:task_output, instance_id, line}) + end + end) + + stream_task_output(parent, instance_id, port) + + {^port, {:exit_status, exit_code}} -> + send(parent, {:task_completed, instance_id, exit_code}) + after + # 30 second timeout + 30_000 -> + Port.close(port) + send(parent, {:task_output, instance_id, "Task timed out after 30 seconds"}) + send(parent, {:task_completed, instance_id, 124}) + end + end +end diff --git a/lib/sig_web/live/dev_tools_live.ex b/lib/sig_web/live/dev_tools_live.ex index c005b72da2813336ea3de1a4879858ecb0243ff1..74f08b5ab0da580452175b290740032cb155c353 100644 --- a/lib/sig_web/live/dev_tools_live.ex +++ b/lib/sig_web/live/dev_tools_live.ex @@ -274,12 +274,12 @@ defmodule SigWeb.DevToolsLive do ~H"""
-
+

Git Status

@@ -289,7 +289,7 @@ defmodule SigWeb.DevToolsLive do
-
+

Automated Workflow

@@ -306,14 +306,14 @@ defmodule SigWeb.DevToolsLive do @@ -348,7 +348,7 @@ defmodule SigWeb.DevToolsLive do @@ -369,14 +369,14 @@ defmodule SigWeb.DevToolsLive do @@ -424,7 +424,7 @@ defmodule SigWeb.DevToolsLive do
-
+

Custom Task Runner

@@ -457,24 +457,37 @@ defmodule SigWeb.DevToolsLive do defp render_mix_task_category(assigns) do ~H""" -
+

{@title}

<%= for task <- @tasks do %> - + <.link + navigate={"/dev/tasks/#{task.name}"} + class="px-3 py-1 text-sm bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors" + > + View +
- +
<% end %>
@@ -485,13 +498,13 @@ defmodule SigWeb.DevToolsLive do ~H"""
-
+

Repository Status

<.render_git_status_content git_status={@git_status} detailed={true} />
-
+

System Health

@@ -504,7 +517,7 @@ defmodule SigWeb.DevToolsLive do defp render_task_history_tab(assigns) do ~H""" -
+

Task History

@@ -570,13 +583,13 @@ defmodule SigWeb.DevToolsLive do defp render_running_tasks_panel(assigns) do ~H""" -
-
-

Running Tasks

+
+
+

Running Tasks

<%= for {task_id, task} <- @running_tasks do %> -
+
mix {task.name}