From a1f485cf7eff43dcc62a4ea10ccfec4a3e3676ed Mon Sep 17 00:00:00 2001 From: Moosieus Date: Sat, 6 Dec 2025 13:18:01 -0500 Subject: [PATCH 01/17] =?UTF-8?q?Overhaul=20progress,=20pending=20further?= =?UTF-8?q?=20overhaul:=20*=20Overhaul=20`Expert.Project.Progress`=20with?= =?UTF-8?q?=20a=20new=20progress=20reporting=20API.=20=20=20*=20Progress?= =?UTF-8?q?=20reporting=20only=20informs=20users=20of=20long-running=20wor?= =?UTF-8?q?k=20and=20doesn=E2=80=99t=20drive=20behavior.=20New=20interface?= =?UTF-8?q?=20accordingly=20KISS.=20*=20Removed=20releveant=20`=5F=5Fusing?= =?UTF-8?q?=5F=5F`=20macros=20in=20favor=20of=20simple=20`alias`=20+=20fun?= =?UTF-8?q?ction=20calls.=20*=20Use=20plain=20tuples=20instead=20of=20`def?= =?UTF-8?q?record`=20for=20progress=20report=20messages=20between=20the=20?= =?UTF-8?q?server=20node=20and=20engine=20node.=20*=20Debounce=20namespace?= =?UTF-8?q?=20build=20logs=20to=20minimize=20spam.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/engine/.formatter.exs | 2 +- apps/engine/lib/engine/api/proxy.ex | 14 +- apps/engine/lib/engine/build/project.ex | 46 ++- apps/engine/lib/engine/build/state.ex | 63 +++-- apps/engine/lib/engine/compilation/tracer.ex | 11 +- apps/engine/lib/engine/dispatch.ex | 9 +- apps/engine/lib/engine/progress.ex | 132 ++++++--- apps/engine/lib/engine/search/indexer.ex | 16 +- apps/engine/test/engine/api/proxy_test.exs | 10 +- apps/engine/test/engine/build/state_test.exs | 4 + .../test/engine/dispatch/handler_test.exs | 2 +- .../engine/dispatch/handlers/indexer_test.exs | 3 + apps/engine/test/engine/progress_test.exs | 69 ++++- .../test/engine/search/indexer_test.exs | 2 + apps/expert/.formatter.exs | 4 +- apps/expert/lib/expert/engine_node.ex | 29 +- apps/expert/lib/expert/project/node.ex | 16 +- apps/expert/lib/expert/project/progress.ex | 219 ++++++++++++-- .../lib/expert/project/progress/percentage.ex | 72 ----- .../lib/expert/project/progress/state.ex | 237 +++++++++++----- .../lib/expert/project/progress/support.ex | 42 --- .../lib/expert/project/progress/value.ex | 51 ---- apps/expert/lib/expert/state.ex | 6 +- apps/expert/test/engine/build_test.exs | 5 +- apps/expert/test/expert/project/node_test.exs | 4 +- .../expert/project/progress/state_test.exs | 168 ++++++++--- .../expert/project/progress/support_test.exs | 33 --- .../test/expert/project/progress_test.exs | 267 +++++++++++++----- apps/forge/lib/forge/engine_api/messages.ex | 11 - .../lib/forge/namespace/transform/beams.ex | 35 ++- 30 files changed, 993 insertions(+), 589 deletions(-) delete mode 100644 apps/expert/lib/expert/project/progress/percentage.ex delete mode 100644 apps/expert/lib/expert/project/progress/support.ex delete mode 100644 apps/expert/lib/expert/project/progress/value.ex delete mode 100644 apps/expert/test/expert/project/progress/support_test.exs diff --git a/apps/engine/.formatter.exs b/apps/engine/.formatter.exs index 4e725b36..f7547916 100644 --- a/apps/engine/.formatter.exs +++ b/apps/engine/.formatter.exs @@ -3,7 +3,7 @@ current_directory = Path.dirname(__ENV__.file) import_deps = [:forge] -locals_without_parens = [with_progress: 2, with_progress: 3, defkey: 2, defkey: 3, with_wal: 2] +locals_without_parens = [defkey: 2, defkey: 3, with_wal: 2] [ locals_without_parens: locals_without_parens, diff --git a/apps/engine/lib/engine/api/proxy.ex b/apps/engine/lib/engine/api/proxy.ex index 61dba9c1..f181f928 100644 --- a/apps/engine/lib/engine/api/proxy.ex +++ b/apps/engine/lib/engine/api/proxy.ex @@ -39,9 +39,7 @@ defmodule Engine.Api.Proxy do alias Engine.Api.Proxy.Records alias Engine.CodeMod alias Engine.Commands - alias Forge.EngineApi.Messages - import Messages import Record import Records, only: :macros @@ -62,7 +60,17 @@ defmodule Engine.Api.Proxy do # proxied functions - def broadcast(percent_progress() = message) do + # Progress messages bypass buffering to ensure timely progress updates + def broadcast({:engine_progress_begin, _, _, _} = message) do + Engine.Dispatch.broadcast(message) + end + + # report and complete are 3-tuples: {type, token, updates_or_opts} + def broadcast({:engine_progress_report, _, _} = message) do + Engine.Dispatch.broadcast(message) + end + + def broadcast({:engine_progress_complete, _, _} = message) do Engine.Dispatch.broadcast(message) end diff --git a/apps/engine/lib/engine/build/project.ex b/apps/engine/lib/engine/build/project.ex index bf8d8eae..80592ed2 100644 --- a/apps/engine/lib/engine/build/project.ex +++ b/apps/engine/lib/engine/build/project.ex @@ -1,12 +1,9 @@ defmodule Engine.Build.Project do alias Forge.Project - - alias Engine.Build + alias Engine.{Build, Plugin, Progress} alias Engine.Build.Isolation - alias Engine.Plugin alias Mix.Task.Compiler.Diagnostic - use Engine.Progress require Logger def compile(%Project{} = project, initial?) do @@ -18,11 +15,10 @@ defmodule Engine.Build.Project do compile_fun = fn -> Mix.Task.clear() - with_progress building_label(project), fn -> - result = compile_in_isolation() - Mix.Task.run(:loadpaths) - result - end + Progress.report(:initialize, message: building_label(project)) + result = compile_in_isolation() + Mix.Task.run(:loadpaths) + result end case compile_fun.() do @@ -72,34 +68,28 @@ defmodule Engine.Build.Project do defp prepare_for_project_build(true = _initial?) do if connected_to_internet?() do - with_progress "mix local.hex", fn -> - Mix.Task.run("local.hex", ~w(--force)) - end + Progress.report(:initialize, message: "mix local.hex") + Mix.Task.run("local.hex", ~w(--force)) - with_progress "mix local.rebar", fn -> - Mix.Task.run("local.rebar", ~w(--force)) - end + Progress.report(:initialize, message: "mix local.rebar") + Mix.Task.run("local.rebar", ~w(--force)) - with_progress "mix deps.get", fn -> - Mix.Task.run("deps.get") - end + Progress.report(:initialize, message: "mix deps.get") + Mix.Task.run("deps.get") else Logger.warning("Could not connect to hex.pm, dependencies will not be fetched") end - with_progress "mix loadconfig", fn -> - Mix.Task.run(:loadconfig) - end + Progress.report(:initialize, message: "mix loadconfig") + Mix.Task.run(:loadconfig) - unless Elixir.Features.compile_keeps_current_directory?() do - with_progress "mix deps.compile", fn -> - Mix.Task.run("deps.safe_compile", ~w(--skip-umbrella-children)) - end + if not Elixir.Features.compile_keeps_current_directory?() do + Progress.report(:initialize, message: "mix deps.compile") + Mix.Task.run("deps.safe_compile", ~w(--skip-umbrella-children)) end - with_progress "loading plugins", fn -> - Plugin.Discovery.run() - end + Progress.report(:initialize, message: "Loading plugins") + Plugin.Discovery.run() end defp connected_to_internet? do diff --git a/apps/engine/lib/engine/build/state.ex b/apps/engine/lib/engine/build/state.ex index ca077b48..ad65f3ff 100644 --- a/apps/engine/lib/engine/build/state.ex +++ b/apps/engine/lib/engine/build/state.ex @@ -2,6 +2,7 @@ defmodule Engine.Build.State do alias Elixir.Features alias Engine.Build alias Engine.Plugin + alias Engine.Progress alias Forge.Document alias Forge.EngineApi.Messages alias Forge.Project @@ -11,8 +12,6 @@ defmodule Engine.Build.State do import Messages - use Engine.Progress - defstruct project: nil, build_number: 0, uri_to_document: %{}, @@ -83,41 +82,49 @@ defmodule Engine.Build.State do project = state.project Build.with_lock(fn -> - compile_requested_message = - project_compile_requested(project: project, build_number: state.build_number) + {:ok, work_done_token} = Progress.begin(building_label(project)) - Engine.broadcast(compile_requested_message) - {elapsed_us, result} = :timer.tc(fn -> Build.Project.compile(project, initial?) end) - elapsed_ms = to_ms(elapsed_us) + try do + compile_requested_message = + project_compile_requested(project: project, build_number: state.build_number) - {compile_message, diagnostics} = - case result do - :ok -> - message = project_compiled(status: :success, project: project, elapsed_ms: elapsed_ms) + Engine.broadcast(compile_requested_message) + {elapsed_us, result} = :timer.tc(fn -> Build.Project.compile(project, initial?) end) + elapsed_ms = to_ms(elapsed_us) - {message, []} + {compile_message, diagnostics} = + case result do + :ok -> + message = + project_compiled(status: :success, project: project, elapsed_ms: elapsed_ms) - {:ok, diagnostics} -> - message = project_compiled(status: :success, project: project, elapsed_ms: elapsed_ms) + {message, []} - {message, List.wrap(diagnostics)} + {:ok, diagnostics} -> + message = + project_compiled(status: :success, project: project, elapsed_ms: elapsed_ms) - {:error, diagnostics} -> - message = project_compiled(status: :error, project: project, elapsed_ms: elapsed_ms) + {message, List.wrap(diagnostics)} - {message, List.wrap(diagnostics)} - end + {:error, diagnostics} -> + message = project_compiled(status: :error, project: project, elapsed_ms: elapsed_ms) - diagnostics_message = - project_diagnostics( - project: project, - build_number: state.build_number, - diagnostics: diagnostics - ) + {message, List.wrap(diagnostics)} + end - Engine.broadcast(compile_message) - Engine.broadcast(diagnostics_message) - Plugin.diagnose(project, state.build_number) + diagnostics_message = + project_diagnostics( + project: project, + build_number: state.build_number, + diagnostics: diagnostics + ) + + Engine.broadcast(compile_message) + Engine.broadcast(diagnostics_message) + Plugin.diagnose(project, state.build_number) + after + Progress.complete(work_done_token) + end end) state diff --git a/apps/engine/lib/engine/compilation/tracer.ex b/apps/engine/lib/engine/compilation/tracer.ex index 6d687984..f29c3ff3 100644 --- a/apps/engine/lib/engine/compilation/tracer.ex +++ b/apps/engine/lib/engine/compilation/tracer.ex @@ -1,6 +1,6 @@ defmodule Engine.Compilation.Tracer do - alias Engine.Build alias Engine.Module.Loader + alias Engine.Progress import Forge.EngineApi.Messages @@ -57,9 +57,7 @@ defmodule Engine.Compilation.Tracer do defp maybe_report_progress(file) do if Path.extname(file) == ".ex" do - file - |> progress_message() - |> Engine.broadcast() + Progress.report(:build, message: progress_message(file)) end end @@ -72,9 +70,6 @@ defmodule Engine.Compilation.Tracer do base_dir = List.first(relative_path_elements) file_name = List.last(relative_path_elements) - message = "compiling: " <> Path.join([base_dir, "...", file_name]) - - label = Build.State.building_label(Engine.get_project()) - project_progress(label: label, message: message) + "compiling: " <> Path.join([base_dir, "...", file_name]) end end diff --git a/apps/engine/lib/engine/dispatch.ex b/apps/engine/lib/engine/dispatch.ex index 752136d3..8b0e7071 100644 --- a/apps/engine/lib/engine/dispatch.ex +++ b/apps/engine/lib/engine/dispatch.ex @@ -10,10 +10,15 @@ defmodule Engine.Dispatch do alias Engine.Dispatch.Handlers alias Engine.Dispatch.PubSub alias Forge.Project - import Forge.EngineApi.Messages @handlers [PubSub, Handlers.Indexing] + @progress_message_types [ + :engine_progress_begin, + :engine_progress_report, + :engine_progress_complete + ] + # public API @doc """ @@ -74,7 +79,7 @@ defmodule Engine.Dispatch do end defp register_progress_listener do - register_listener(progress_pid(), [project_progress(), percent_progress()]) + register_listener(progress_pid(), @progress_message_types) end defp progress_pid do diff --git a/apps/engine/lib/engine/progress.ex b/apps/engine/lib/engine/progress.ex index 2c724b62..50191a2f 100644 --- a/apps/engine/lib/engine/progress.ex +++ b/apps/engine/lib/engine/progress.ex @@ -1,66 +1,108 @@ defmodule Engine.Progress do - import Forge.EngineApi.Messages + @moduledoc """ + LSP progress reporting for engine operations. + """ - @type label :: String.t() - @type message :: String.t() + @type work_result :: {:done, term()} | {:done, term(), String.t()} | {:cancel, term()} + @type work_fn :: (integer() -> work_result()) - @type delta :: pos_integer() - @type on_complete_callback :: (-> any()) - @type report_progress_callback :: (delta(), message() -> any()) + @doc """ + Wraps work with progress reporting. - defmacro __using__(_) do - quote do - import unquote(__MODULE__), only: [with_progress: 2] - end - end + The `work_fn` receives the progress token and can call `Progress.report/2` directly: + + with_progress("Indexing", fn token -> + Progress.report(token, message: "Processing...") + do_work() + {:done, :ok} + end) + + The `work_fn` must return one of: + - `{:done, result}` - Operation completed successfully + - `{:done, result, message}` - Completed with a final message + - `{:cancel, result}` - Operation was cancelled + + ## Options - @spec with_progress(label(), (-> any())) :: any() - def with_progress(label, func) when is_function(func, 0) do - on_complete = begin_progress(label) + - `:message` - Initial status message (optional) + - `:percentage` - Initial percentage 0-100 (optional) + - `:cancellable` - Whether the client can cancel (default: false) + """ + @spec with_progress(String.t(), work_fn(), keyword()) :: term() + def with_progress(title, work_fn, opts \\ []) when is_function(work_fn, 1) do + opts = Keyword.validate!(opts, [:message, :percentage, :cancellable]) + + token = begin(title, opts) try do - func.() - after - on_complete.() + case work_fn.(token) do + {:done, result} -> + complete(token) + result + + {:done, result, message} -> + complete(token, message: message) + result + + {:cancel, result} -> + complete(token, message: "Cancelled") + result + end + rescue + e -> + complete(token, message: "Error: #{Exception.message(e)}") + reraise e, __STACKTRACE__ end end - @spec with_percent_progress(label(), pos_integer(), (report_progress_callback() -> any())) :: - any() - def with_percent_progress(label, max, func) when is_function(func, 1) do - {report_progress, on_complete} = begin_percent(label, max) + @doc """ + Manually begins a progress sequence with the given title. - try do - func.(report_progress) - after - on_complete.() - end + Generates a token internally and returns it for use with subsequent `report/2` and `complete/2` calls. + + ## Options + + - `:message` - Initial status message + - `:percentage` - Initial percentage 0-100 + - `:cancellable` - Whether the client can cancel + - `:ref` - Atom ref to associate with this progress (e.g. `:build`). + Allows using `report/2` and `complete/2` with the ref instead of the token. + """ + @spec begin(String.t(), keyword()) :: integer() + def begin(title, opts \\ []) do + token = System.unique_integer([:positive]) + Engine.broadcast({:engine_progress_begin, token, title, opts}) + token end - @spec begin_progress(label :: label()) :: on_complete_callback() - def begin_progress(label) do - Engine.broadcast(project_progress(label: label, stage: :begin)) + @doc """ + Reports progress for an in-progress operation. - fn -> - Engine.broadcast(project_progress(label: label, stage: :complete)) - end + Accepts either a token (integer returned by `begin/2`) or a ref (atom registered + via `begin/2` with `:ref` option, or `:initialize` for client-initiated progress). + + ## Options + + - `:message` - Status message to display + - `:percentage` - Progress percentage 0-100 + """ + @spec report(integer() | atom(), keyword()) :: :ok + def report(token_or_ref, updates \\ []) do + Engine.broadcast({:engine_progress_report, token_or_ref, updates}) end - @spec begin_percent(label(), pos_integer()) :: - {report_progress_callback(), on_complete_callback()} - def begin_percent(label, max) do - Engine.broadcast(percent_progress(label: label, max: max, stage: :begin)) + @doc """ + Completes a progress sequence. - report_progress = fn delta, message -> - Engine.broadcast( - percent_progress(label: label, message: message, delta: delta, stage: :report) - ) - end + Accepts either a token (integer returned by `begin/2`) or a ref (atom registered + via `begin/2` with `:ref` option, or `:initialize` for client-initiated progress). - complete = fn -> - Engine.broadcast(percent_progress(label: label, stage: :complete)) - end + ## Options - {report_progress, complete} + - `:message` - Final completion message + """ + @spec complete(integer() | atom(), keyword()) :: :ok + def complete(token_or_ref, opts \\ []) do + Engine.broadcast({:engine_progress_complete, token_or_ref, opts}) end end diff --git a/apps/engine/lib/engine/search/indexer.ex b/apps/engine/lib/engine/search/indexer.ex index 740b1e75..69dc26b1 100644 --- a/apps/engine/lib/engine/search/indexer.ex +++ b/apps/engine/lib/engine/search/indexer.ex @@ -84,6 +84,7 @@ defmodule Engine.Search.Indexer do # 128 K blocks indexed expert in 5.3 seconds @bytes_per_block 1024 * 128 + defp async_chunks(file_paths, processor, timeout \\ :infinity) do # this function tries to even out the amount of data processed by # async stream by making each chunk emitted by the initial stream to @@ -101,8 +102,9 @@ defmodule Engine.Search.Indexer do total_bytes = paths_to_sizes |> Enum.map(&elem(&1, 1)) |> Enum.sum() if total_bytes > 0 do - {on_update_progress, on_complete} = - Progress.begin_percent("Indexing source code", total_bytes) + # Start progress tracking + token = Progress.begin("Indexing source code", percentage: 0) + bytes_processed = :counters.new(1, [:atomics]) initial_state = {0, []} @@ -131,7 +133,13 @@ defmodule Engine.Search.Indexer do fn chunk -> block_bytes = chunk |> Enum.map(&Map.get(path_to_size_map, &1)) |> Enum.sum() result = Enum.map(chunk, processor) - on_update_progress.(block_bytes, "Indexing") + + :counters.add(bytes_processed, 1, block_bytes) + processed = :counters.get(bytes_processed, 1) + percentage = min(100, div(processed * 100, total_bytes)) + + Progress.report(token, message: "Indexing", percentage: percentage) + result end, timeout: timeout @@ -150,7 +158,7 @@ defmodule Engine.Search.Indexer do {chunk_items, acc} end, fn _acc -> - on_complete.() + Progress.complete(token) end ) else diff --git a/apps/engine/test/engine/api/proxy_test.exs b/apps/engine/test/engine/api/proxy_test.exs index fcf92d04..3cc68363 100644 --- a/apps/engine/test/engine/api/proxy_test.exs +++ b/apps/engine/test/engine/api/proxy_test.exs @@ -34,9 +34,10 @@ defmodule Engine.Api.ProxyTest do test "proxies broadcasts of progress messages" do patch(Dispatch, :broadcast, :ok) - assert :ok = Proxy.broadcast(percent_progress()) + progress_message = {:engine_progress_report, 123, [message: "testing"]} + assert :ok = Proxy.broadcast(progress_message) - assert_called(Dispatch.broadcast(percent_progress())) + assert_called(Dispatch.broadcast(^progress_message)) end test "schedule compile is proxied", %{project: project} do @@ -152,9 +153,10 @@ defmodule Engine.Api.ProxyTest do test "proxies broadcasts of progress messages" do patch(Dispatch, :broadcast, :ok) - assert :ok = Proxy.broadcast(percent_progress()) + progress_message = {:engine_progress_begin, 123, "test", []} + assert :ok = Proxy.broadcast(progress_message) - assert_called(Dispatch.broadcast(percent_progress())) + assert_called(Dispatch.broadcast(^progress_message)) end test "buffers broadcasts" do diff --git a/apps/engine/test/engine/build/state_test.exs b/apps/engine/test/engine/build/state_test.exs index b9e834fa..6d024ae8 100644 --- a/apps/engine/test/engine/build/state_test.exs +++ b/apps/engine/test/engine/build/state_test.exs @@ -68,6 +68,10 @@ defmodule Engine.Build.StateTest do def with_patched_compilation(_) do patch(Build.Document, :compile, :ok) patch(Build.Project, :compile, :ok) + # Patch Progress and building_label to avoid side effects during state tests + patch(State, :building_label, "Building test") + patch(Engine.Progress, :begin, fn _, _ -> 0 end) + patch(Engine.Progress, :complete, fn _, _ -> :ok end) :ok end diff --git a/apps/engine/test/engine/dispatch/handler_test.exs b/apps/engine/test/engine/dispatch/handler_test.exs index 59c65995..ed4a5ffb 100644 --- a/apps/engine/test/engine/dispatch/handler_test.exs +++ b/apps/engine/test/engine/dispatch/handler_test.exs @@ -69,7 +69,7 @@ defmodule Engine.Dispatch.HandlerTest do Dispatch.broadcast(file_changed()) refute_receive {SelectiveForwarder, _} - Dispatch.broadcast(project_progress()) + Dispatch.broadcast(project_compiled()) refute_receive {SelectiveForwarder, _} end end diff --git a/apps/engine/test/engine/dispatch/handlers/indexer_test.exs b/apps/engine/test/engine/dispatch/handlers/indexer_test.exs index ca4776a3..ed3cba97 100644 --- a/apps/engine/test/engine/dispatch/handlers/indexer_test.exs +++ b/apps/engine/test/engine/dispatch/handlers/indexer_test.exs @@ -19,6 +19,9 @@ defmodule Engine.Dispatch.Handlers.IndexingTest do create_index = &Search.Indexer.create_index/1 update_index = &Search.Indexer.update_index/2 + # Mock the broadcast so progress reporting doesn't fail + patch(Engine.Api.Proxy, :broadcast, fn _ -> :ok end) + start_supervised!(Engine.Dispatch) start_supervised!(Commands.Reindex) start_supervised!(Search.Store.Backends.Ets) diff --git a/apps/engine/test/engine/progress_test.exs b/apps/engine/test/engine/progress_test.exs index 19b12a04..60ae22e7 100644 --- a/apps/engine/test/engine/progress_test.exs +++ b/apps/engine/test/engine/progress_test.exs @@ -1,11 +1,8 @@ defmodule Engine.ProgressTest do - alias Engine.Progress - - import Forge.EngineApi.Messages - use ExUnit.Case use Patch - use Progress + + alias Engine.Progress setup do test_pid = self() @@ -14,19 +11,67 @@ defmodule Engine.ProgressTest do end test "it should send begin/complete event and return the result" do - result = with_progress "foo", fn -> :ok end + result = Progress.with_progress("foo", fn _token -> {:done, :ok} end) assert result == :ok - assert_received project_progress(label: "foo", stage: :begin) - assert_received project_progress(label: "foo", stage: :complete) + assert_received {:engine_progress_begin, token, "foo", []} when is_integer(token) + assert_received {:engine_progress_complete, ^token, []} end - test "it should send begin/complete event even there is an exception" do + test "it should send begin/complete event with final message" do + result = Progress.with_progress("bar", fn _token -> {:done, :success, "Completed!"} end) + + assert result == :success + assert_received {:engine_progress_begin, token, "bar", []} when is_integer(token) + assert_received {:engine_progress_complete, ^token, [message: "Completed!"]} + end + + test "it should send report events when Progress.report is called" do + result = + Progress.with_progress("indexing", fn token -> + Progress.report(token, message: "Processing file 1...") + Progress.report(token, message: "Processing file 2...", percentage: 50) + {:done, :indexed} + end) + + assert result == :indexed + assert_received {:engine_progress_begin, token, "indexing", []} when is_integer(token) + assert_received {:engine_progress_report, ^token, [message: "Processing file 1..."]} + + assert_received {:engine_progress_report, ^token, + [message: "Processing file 2...", percentage: 50]} + + assert_received {:engine_progress_complete, ^token, []} + end + + test "it should send begin/complete event even when there is an exception" do assert_raise(Mix.Error, fn -> - with_progress "compile", fn -> raise Mix.Error, "can't compile" end + Progress.with_progress("compile", fn _token -> raise Mix.Error, "can't compile" end) end) - assert_received project_progress(label: "compile", stage: :begin) - assert_received project_progress(label: "compile", stage: :complete) + assert_received {:engine_progress_begin, token, "compile", []} when is_integer(token) + assert_received {:engine_progress_complete, ^token, [message: "Error: can't compile"]} + end + + test "it should handle cancel result" do + result = Progress.with_progress("cancellable", fn _token -> {:cancel, :cancelled} end) + + assert result == :cancelled + assert_received {:engine_progress_begin, token, "cancellable", []} when is_integer(token) + assert_received {:engine_progress_complete, ^token, [message: "Cancelled"]} + end + + test "it should pass through initial options" do + _result = + Progress.with_progress( + "with_opts", + fn _token -> {:done, :ok} end, + message: "Starting...", + percentage: 0 + ) + + assert_received {:engine_progress_begin, _token, "with_opts", opts} + assert opts[:message] == "Starting..." + assert opts[:percentage] == 0 end end diff --git a/apps/engine/test/engine/search/indexer_test.exs b/apps/engine/test/engine/search/indexer_test.exs index 25965518..f74e7410 100644 --- a/apps/engine/test/engine/search/indexer_test.exs +++ b/apps/engine/test/engine/search/indexer_test.exs @@ -26,6 +26,8 @@ defmodule Engine.Search.IndexerTest do setup do project = project() start_supervised(Dispatch) + # Mock the broadcast so progress reporting doesn't fail + patch(Engine.Api.Proxy, :broadcast, fn _ -> :ok end) {:ok, project: project} end diff --git a/apps/expert/.formatter.exs b/apps/expert/.formatter.exs index f56e60d1..d7949373 100644 --- a/apps/expert/.formatter.exs +++ b/apps/expert/.formatter.exs @@ -6,10 +6,8 @@ imported_deps = [:forge] end -locals_without_parens = [with_progress: 3] - [ - locals_without_parens: locals_without_parens, + locals_without_parens: [], inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], import_deps: imported_deps ] diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index a3ce8730..d1c72951 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -2,8 +2,6 @@ defmodule Expert.EngineNode do alias Forge.Project require Logger - use Expert.Project.Progress.Support - defmodule State do defstruct [ :project, @@ -222,17 +220,24 @@ defmodule Expert.EngineNode do GenLSP.info(lsp, "Finding or building engine for project #{project_name}") - with_progress(project, "Building engine for #{project_name}", fn -> - fn -> - Process.flag(:trap_exit, true) - - {:spawn_executable, launcher} - |> Port.open(opts) - |> wait_for_engine() + Expert.Project.Progress.with_server_progress( + project, + "Building engine for #{project_name}", + fn _token -> + result = + fn -> + Process.flag(:trap_exit, true) + + {:spawn_executable, launcher} + |> Port.open(opts) + |> wait_for_engine() + end + |> Task.async() + |> Task.await(:infinity) + + {:done, result, "Engine node built for #{project_name}."} end - |> Task.async() - |> Task.await(:infinity) - end) + ) {:error, :no_elixir, message} -> GenLSP.error(Expert.get_lsp(), message) diff --git a/apps/expert/lib/expert/project/node.ex b/apps/expert/lib/expert/project/node.ex index b91e40c0..0dcde98d 100644 --- a/apps/expert/lib/expert/project/node.ex +++ b/apps/expert/lib/expert/project/node.ex @@ -20,7 +20,6 @@ defmodule Expert.Project.Node do require Logger use GenServer - use Progress.Support def start_link(%Project{} = project) do GenServer.start_link(__MODULE__, project, name: name(project)) @@ -51,12 +50,15 @@ defmodule Expert.Project.Node do @impl GenServer def init(%Project{} = project) do - case with_progress(project, "Project Node", fn -> start_node(project) end) do - {:ok, state} -> - {:ok, state, {:continue, :trigger_build}} - - error -> - {:stop, error} + project + |> Progress.with_server_progress("Project Node", fn _token -> + result = start_node(project) + + {:done, result, "Project node started"} + end) + |> case do + {:ok, state} -> {:ok, state, {:continue, :trigger_build}} + error -> {:stop, error} end end diff --git a/apps/expert/lib/expert/project/progress.ex b/apps/expert/lib/expert/project/progress.ex index 73561c28..dba8e335 100644 --- a/apps/expert/lib/expert/project/progress.ex +++ b/apps/expert/lib/expert/project/progress.ex @@ -2,12 +2,153 @@ defmodule Expert.Project.Progress do alias Expert.Project.Progress.State alias Forge.Project - import Forge.EngineApi.Messages - use GenServer + @type work_result :: {:done, term()} | {:done, term(), String.t()} | {:cancel, term()} + @type work_fn :: (integer() | String.t() -> work_result()) + + defguardp is_token(token) when is_binary(token) or is_integer(token) + + @doc """ + Wraps a function with server-initiated progress reporting. + + The function receives the progress token and can call `Progress.report/3` directly: + + Progress.with_server_progress(project, "Building", fn token -> + Progress.report(project, token, message: "Compiling...") + compile() + {:done, :ok, "Build complete"} + end) + + ## Options + + * `:message` - Initial status message (optional) + * `:percentage` - Initial percentage 0-100 (optional) + * `:cancellable` - Whether the client can cancel (default: false) + """ + @spec with_server_progress(Project.t(), String.t(), work_fn(), keyword()) :: term() + def with_server_progress(project, title, func, opts \\ []) when is_function(func, 1) do + opts = Keyword.validate!(opts, [:message, :percentage, :cancellable]) + {:ok, token} = begin(project, title, opts) + run_work(project, token, func) + end + + @doc """ + Wraps a function with client-initiated progress reporting, and closes it on completion. + + The function receives the progress token and can call `Progress.report/3` directly: + + Progress.with_client_progress(project, client_token, fn token -> + Progress.report(project, token, message: "Compiling...") + compile() + {:done, :ok, "Build complete"} + end) + """ + @spec with_client_progress(Project.t(), integer() | String.t(), work_fn()) :: term() + def with_client_progress(project, client_token, func) + when is_function(func, 1) and is_token(client_token) do + :ok = register(project, client_token) + run_work(project, client_token, func) + end + + defp run_work(project, token, func) do + try do + case func.(token) do + {:done, result} -> + complete(project, token, []) + result + + {:done, result, message} -> + complete(project, token, message: message) + result + + {:cancel, result} -> + complete(project, token, message: "Cancelled") + result + end + rescue + e -> + complete(project, token, message: "Error: #{Exception.message(e)}") + reraise e, __STACKTRACE__ + end + end + + @doc """ + Manually registers a client-initiated progress token. + + ## Options + + * `:ref` - An atom to use as a stable identifier for this progress (optional). + + ## Examples + + :ok = Progress.register(project, client_work_token, ref: :initialize) + """ + @spec register(Project.t(), integer() | String.t(), keyword()) :: :ok + def register(project, client_token, opts \\ []) do + GenServer.call(name(project), {:register, client_token, opts}) + end + + @doc """ + Manually begins a server-initiated progress. + + ## Options + + * `:message` - Initial status message (optional) + * `:percentage` - Initial percentage 0-100 (optional) + * `:cancellable` - Whether the client can cancel (default: false) + + ## Examples + + {:ok, work_done_token} = Progress.begin(project, "Building", message: "Starting...") + """ + @spec begin(Project.t(), String.t(), keyword()) :: {:ok, integer()} | {:error, :rejected} + def begin(project, title, opts \\ []) do + GenServer.call(name(project), {:begin, title, opts}) + end + + @doc """ + Reports progress update (fire-and-forget). + + This is a cast operation - it returns immediately without waiting for confirmation. + If the token/ref doesn't exist, the update is silently ignored with a warning log. + + ## Options + + * `:message` - Status message (optional) + * `:percentage` - Percentage 0-100 (optional) + + ## Examples + + Progress.report(project, :initialize, message: "Loading...") + Progress.report(project, work_done_token, message: "Processing...", percentage: 50) + """ + @spec report(Project.t(), atom() | integer() | String.t(), keyword()) :: :ok + def report(project, token_or_ref, opts \\ []) do + GenServer.cast(name(project), {:report, token_or_ref, opts}) + end + + @doc """ + Manually ends a progress token. + + ## Options + + * `:message` - Final message, typically indicating some outcome (optional). + + ## Examples + + :ok = Progress.complete(project, work_done_token, message: "Done!") + :ok = Progress.complete(project, :initialize, message: "Ready") + """ + @spec complete(Project.t(), atom() | integer() | String.t(), keyword()) :: :ok + def complete(project, token_or_ref, opts \\ []) do + GenServer.call(name(project), {:end, token_or_ref, opts}) + end + + # GenServer API + def start_link(%Project{} = project) do - GenServer.start_link(__MODULE__, [project], name: name(project)) + GenServer.start_link(__MODULE__, project, name: name(project)) end def child_spec(%Project{} = project) do @@ -17,30 +158,74 @@ defmodule Expert.Project.Progress do } end - # GenServer callbacks + @impl GenServer + def init(project) do + state = State.new(project) + + {:ok, state} + end @impl GenServer - def init([project]) do - {:ok, State.new(project)} + def handle_call({:register, token, opts}, _from, %State{} = state) when is_token(token) do + {:ok, new_state} = State.register(state, token, opts) + {:reply, :ok, new_state} end - @impl true - def handle_info(project_progress(stage: stage) = message, %State{} = state) do - new_state = apply(State, stage, [state, message]) - {:noreply, new_state} + def handle_call({:begin, title, opts}, _from, %State{} = state) do + case State.begin(state, title, opts) do + {:ok, token, new_state} -> {:reply, {:ok, token}, new_state} + {:error, :rejected} -> {:reply, {:error, :rejected}, state} + end + end + + def handle_call({:end, token, opts}, _from, %State{} = state) when is_token(token) do + case State.complete(state, token, opts) do + {:ok, new_state} -> {:reply, :ok, new_state} + {:error, :unknown_token, state} -> {:reply, :ok, state} + end + end + + def handle_call({:end, ref, opts}, _from, %State{} = state) when is_atom(ref) do + case State.complete(state, ref, opts) do + {:ok, new_state} -> {:reply, :ok, new_state} + {:error, :unknown_ref} -> {:reply, :ok, state} + end + end + + @impl GenServer + def handle_cast({:report, token_or_ref, opts}, %State{} = state) do + case State.report(state, token_or_ref, opts) do + {:ok, _token, new_state} -> {:noreply, new_state} + {:noop, state} -> {:noreply, state} + end end - def handle_info(percent_progress(stage: stage) = message, %State{} = state) do - new_state = apply(State, stage, [state, message]) + # Engine Node handlers - {:noreply, new_state} + @impl true + def handle_info({:engine_progress_begin, token, title, opts}, %State{} = state) do + case State.register_engine_token(state, token, title, opts) do + {:ok, new_state} -> {:noreply, new_state} + {:error, :rejected} -> {:noreply, state} + end end - def name(%Project{} = project) do - :"#{Project.name(project)}::progress" + def handle_info({:engine_progress_report, token, updates}, %State{} = state) do + case State.report(state, token, updates) do + {:ok, _token, new_state} -> {:noreply, new_state} + {:noop, state} -> {:noreply, state} + end end - def whereis(%Project{} = project) do - project |> name() |> Process.whereis() + def handle_info({:engine_progress_complete, token_or_ref, opts}, %State{} = state) do + case State.complete(state, token_or_ref, opts) do + {:ok, new_state} -> {:noreply, new_state} + {:error, :unknown_token, state} -> {:noreply, state} + {:error, :unknown_ref} -> {:noreply, state} + end end + + def name(%Project{} = project), do: :"#{Project.name(project)}::progress" + + def whereis(%Project{} = project), do: project |> name() |> Process.whereis() end diff --git a/apps/expert/lib/expert/project/progress/percentage.ex b/apps/expert/lib/expert/project/progress/percentage.ex deleted file mode 100644 index 47130e4d..00000000 --- a/apps/expert/lib/expert/project/progress/percentage.ex +++ /dev/null @@ -1,72 +0,0 @@ -defmodule Expert.Project.Progress.Percentage do - @moduledoc """ - The backing data structure for percentage based progress reports - """ - alias Forge.Math - alias GenLSP.Notifications - alias GenLSP.Structures - - @enforce_keys [:token, :kind, :max] - defstruct [:token, :kind, :title, :message, :max, current: 0] - - def begin(title, max) do - token = System.unique_integer([:positive]) - %__MODULE__{token: token, kind: :begin, title: title, max: max} - end - - def report(percentage, delta, message \\ "") - - def report(%__MODULE__{} = percentage, delta, message) when is_integer(delta) and delta >= 0 do - new_current = percentage.current + delta - - %__MODULE__{percentage | kind: :report, message: message, current: new_current} - end - - def report(%__MODULE__{} = percentage, delta, _message) when is_integer(delta) do - percentage - end - - def report(_, _, _) do - nil - end - - def complete(%__MODULE__{} = percentage, message) do - %__MODULE__{percentage | kind: :end, current: percentage.max, message: message} - end - - def to_protocol(%__MODULE__{kind: :begin} = value) do - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: value.token, - value: %Structures.WorkDoneProgressBegin{kind: "begin", title: value.title, percentage: 0} - } - } - end - - def to_protocol(%__MODULE__{kind: :report} = value) do - percent_complete = - (value.current / value.max * 100) - |> round() - |> Math.clamp(0, 100) - - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: value.token, - value: %Structures.WorkDoneProgressReport{ - kind: "report", - message: value.message, - percentage: percent_complete - } - } - } - end - - def to_protocol(%__MODULE__{kind: :end} = value) do - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: value.token, - value: %Structures.WorkDoneProgressEnd{kind: "end", message: value.message} - } - } - end -end diff --git a/apps/expert/lib/expert/project/progress/state.ex b/apps/expert/lib/expert/project/progress/state.ex index ee03aea5..58dca0c5 100644 --- a/apps/expert/lib/expert/project/progress/state.ex +++ b/apps/expert/lib/expert/project/progress/state.ex @@ -1,111 +1,206 @@ defmodule Expert.Project.Progress.State do alias Expert.Configuration - alias Expert.Project.Progress.Percentage - alias Expert.Project.Progress.Value alias Expert.Protocol.Id alias Forge.Project alias GenLSP.Requests alias GenLSP.Structures - import Forge.EngineApi.Messages + require Logger - defstruct project: nil, progress_by_label: %{} + defstruct project: nil, active: MapSet.new(), refs: %{} - def new(%Project{} = project) do - %__MODULE__{project: project} - end + defguardp is_token(token) when is_binary(token) or is_integer(token) - def begin(%__MODULE__{} = state, project_progress(label: label)) do - progress = Value.begin(label) - progress_by_label = Map.put(state.progress_by_label, label, progress) + def new(%Project{} = project), do: %__MODULE__{project: project} - write_work_done(Expert.get_lsp(), progress.token) - write(Expert.get_lsp(), progress) + @doc """ + Registers a client-initiated progress token with an optional `:ref`, which can be + used as a stable identifier to report and end progress. + """ + def register(%__MODULE__{} = state, token, opts \\ []) do + opts = Keyword.validate!(opts, [:ref]) - %__MODULE__{state | progress_by_label: progress_by_label} - end + active = MapSet.put(state.active, token) + state = %{state | active: active} - def begin(%__MODULE__{} = state, percent_progress(label: label, max: max)) do - progress = Percentage.begin(label, max) - progress_by_label = Map.put(state.progress_by_label, label, progress) - write_work_done(Expert.get_lsp(), progress.token) - write(Expert.get_lsp(), progress) + case Keyword.get(opts, :ref) do + nil -> + {:ok, state} - %__MODULE__{state | progress_by_label: progress_by_label} + ref when is_atom(ref) -> + refs = Map.put(state.refs, ref, token) + {:ok, %{state | refs: refs}} + end end - def report(%__MODULE__{} = state, project_progress(label: label, message: message)) do - {progress, progress_by_label} = - Map.get_and_update(state.progress_by_label, label, fn old_value -> - new_value = Value.report(old_value, message) - {new_value, new_value} - end) - - write(Expert.get_lsp(), progress) - %__MODULE__{state | progress_by_label: progress_by_label} + @doc """ + Begins server-initiated progress. + + Generates a token, requests the client create the progress indicator, + and sends the begin notification. + """ + def begin(%__MODULE__{} = state, title, opts) do + lsp = Expert.get_lsp() + token = System.unique_integer([:positive]) + + case request_work_done_progress(lsp, token) do + :ok -> + notify_begin(lsp, token, title, opts) + active = MapSet.put(state.active, token) + {:ok, token, %{state | active: active}} + + {:error, reason} -> + Logger.warning("Client rejected progress token: #{inspect(reason)}") + {:error, :rejected} + end end - def report( - %__MODULE__{} = state, - percent_progress(label: label, message: message, delta: delta) - ) do - {progress, progress_by_label} = - Map.get_and_update(state.progress_by_label, label, fn old_percentage -> - new_percentage = Percentage.report(old_percentage, delta, message) - {new_percentage, new_percentage} - end) - - write(Expert.get_lsp(), progress) - %__MODULE__{state | progress_by_label: progress_by_label} - end + @doc """ + Registers an engine-initiated progress token. + + The token is generated by the engine and passed here. Requests the client + create the progress indicator, sends the begin notification, and adds to active. + + If opts contains `:ref`, also registers a ref → token mapping so the engine + can use `report_to_ref` and `complete_ref` without tracking the token. + """ + def register_engine_token(%__MODULE__{} = state, token, title, opts) when is_token(token) do + lsp = Expert.get_lsp() - def complete(%__MODULE__{} = state, project_progress(label: label, message: message)) do - {progress, progress_by_label} = - Map.get_and_update(state.progress_by_label, label, fn _ -> :pop end) + case request_work_done_progress(lsp, token) do + :ok -> + notify_begin(lsp, token, title, opts) + active = MapSet.put(state.active, token) + state = %{state | active: active} - case progress do - %Value{} = progress -> - write(Expert.get_lsp(), Value.complete(progress, message)) + # If opts contains :ref, register the ref → token mapping + state = + case Keyword.get(opts, :ref) do + nil -> state + ref when is_atom(ref) -> %{state | refs: Map.put(state.refs, ref, token)} + end + + {:ok, state} + + {:error, reason} -> + Logger.warning("Client rejected engine progress token: #{inspect(reason)}") + {:error, :rejected} + end + end - _ -> - :ok + @doc """ + Reports progress to the client. + + Returns `{:ok, token, state}` on success, `{:noop, state}` if token/ref not found. + """ + def report(%__MODULE__{} = state, token, updates) when is_token(token) do + if MapSet.member?(state.active, token) do + lsp = Expert.get_lsp() + notify_report(lsp, token, updates) + {:ok, token, state} + else + Logger.warning("Progress report for unknown token: #{inspect(token)}") + {:noop, state} end + end - %__MODULE__{state | progress_by_label: progress_by_label} + def report(%__MODULE__{} = state, ref, updates) when is_atom(ref) do + case Map.fetch(state.refs, ref) do + {:ok, token} -> report(state, token, updates) + :error -> {:noop, state} + end end - def complete(%__MODULE__{} = state, percent_progress(label: label, message: message)) do - {progress, progress_by_label} = - Map.get_and_update(state.progress_by_label, label, fn _ -> :pop end) + @doc """ + Completes a progress sequence by ref atom or token. + """ + def complete(state, ref_or_token, opts) - case progress do - %Percentage{} = progress -> - write(Expert.get_lsp(), Percentage.complete(progress, message)) + def complete(%__MODULE__{} = state, ref, opts) when is_atom(ref) do + case Map.pop(state.refs, ref) do + {token, refs} when is_token(token) -> complete(%{state | refs: refs}, token, opts) + {nil, _} -> {:error, :unknown_ref} + end + end - nil -> - :ok + def complete(%__MODULE__{} = state, token, opts) when is_token(token) do + if MapSet.member?(state.active, token) do + lsp = Expert.get_lsp() + notify_end(lsp, token, opts) + active = MapSet.delete(state.active, token) + {:ok, %{state | active: active}} + else + Logger.warning("Progress complete for unknown token: #{inspect(token)}") + {:error, :unknown_token, state} end + end - %__MODULE__{state | progress_by_label: progress_by_label} + defp notify_begin(lsp, token, title, opts) do + if Configuration.client_supports?(:work_done_progress) do + :ok = + GenLSP.notify(lsp, %GenLSP.Notifications.DollarProgress{ + params: %Structures.ProgressParams{ + token: token, + value: %Structures.WorkDoneProgressBegin{ + kind: "begin", + title: title, + message: Keyword.get(opts, :message), + percentage: Keyword.get(opts, :percentage), + cancellable: Keyword.get(opts, :cancellable) + } + } + }) + end end - defp write_work_done(lsp, token) do + defp notify_report(lsp, token, updates) do if Configuration.client_supports?(:work_done_progress) do - GenLSP.request(lsp, %Requests.WindowWorkDoneProgressCreate{ - id: Id.next(), - params: %Structures.WorkDoneProgressCreateParams{token: token} - }) + :ok = + GenLSP.notify(lsp, %GenLSP.Notifications.DollarProgress{ + params: %Structures.ProgressParams{ + token: token, + value: %Structures.WorkDoneProgressReport{ + kind: "report", + message: Keyword.get(updates, :message), + percentage: Keyword.get(updates, :percentage) + } + } + }) end end - defp write(lsp, %progress_module{token: token} = progress) when not is_nil(token) do + defp notify_end(lsp, token, opts) do if Configuration.client_supports?(:work_done_progress) do - GenLSP.notify( - lsp, - progress_module.to_protocol(progress) - ) + :ok = + GenLSP.notify(lsp, %GenLSP.Notifications.DollarProgress{ + params: %Structures.ProgressParams{ + token: token, + value: %Structures.WorkDoneProgressEnd{ + kind: "end", + message: Keyword.get(opts, :message) + } + } + }) end end - defp write(_, _), do: :ok + defp request_work_done_progress(lsp, token) do + if Configuration.client_supports?(:work_done_progress) do + result = + GenLSP.request( + lsp, + %Requests.WindowWorkDoneProgressCreate{ + id: Id.next(), + params: %Structures.WorkDoneProgressCreateParams{token: token} + } + ) + + case result do + nil -> :ok + error -> {:error, error} + end + else + :ok + end + end end diff --git a/apps/expert/lib/expert/project/progress/support.ex b/apps/expert/lib/expert/project/progress/support.ex deleted file mode 100644 index 148f5f27..00000000 --- a/apps/expert/lib/expert/project/progress/support.ex +++ /dev/null @@ -1,42 +0,0 @@ -defmodule Expert.Project.Progress.Support do - alias Expert.Project.Progress - alias Forge.Project - - import Forge.EngineApi.Messages - - defmacro __using__(_) do - quote do - import unquote(__MODULE__), only: [with_progress: 3] - end - end - - def with_progress(project, label, func) when is_function(func, 0) do - dest = Progress.name(project) - - try do - send(dest, project_progress(label: label, stage: :begin)) - func.() - after - send(dest, project_progress(label: label, stage: :complete)) - end - end - - def with_percentage_progress(%Project{} = project, label, max, func) - when is_function(func, 1) do - dest = Progress.name(project) - - report_progress = fn delta, message -> - message = - percent_progress(label: label, max: max, message: message, delta: delta, stage: :report) - - send(dest, message) - end - - try do - send(dest, percent_progress(label: label, max: max, stage: :begin)) - func.(report_progress) - after - send(dest, percent_progress(label: label, stage: :complete)) - end - end -end diff --git a/apps/expert/lib/expert/project/progress/value.ex b/apps/expert/lib/expert/project/progress/value.ex deleted file mode 100644 index 59f3ae3c..00000000 --- a/apps/expert/lib/expert/project/progress/value.ex +++ /dev/null @@ -1,51 +0,0 @@ -defmodule Expert.Project.Progress.Value do - alias GenLSP.Notifications - alias GenLSP.Structures - - @enforce_keys [:token, :kind] - defstruct [:token, :kind, :title, :message] - - def begin(title) do - token = System.unique_integer([:positive]) - %__MODULE__{token: token, kind: :begin, title: title} - end - - def report(%__MODULE__{token: token}, message) do - %__MODULE__{token: token, kind: :report, message: message} - end - - def report(_, _) do - nil - end - - def complete(%__MODULE__{token: token}, message) do - %__MODULE__{token: token, kind: :end, message: message} - end - - def to_protocol(%__MODULE__{kind: :begin} = value) do - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: value.token, - value: %Structures.WorkDoneProgressBegin{kind: "begin", title: value.title} - } - } - end - - def to_protocol(%__MODULE__{kind: :report} = value) do - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: value.token, - value: %Structures.WorkDoneProgressReport{kind: "report", message: value.message} - } - } - end - - def to_protocol(%__MODULE__{kind: :end} = value) do - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: value.token, - value: %Structures.WorkDoneProgressEnd{kind: "end", message: value.message} - } - } - end -end diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index 655d2ca8..ed894d79 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -55,10 +55,8 @@ defmodule Expert.State do response = initialize_result() - Task.Supervisor.start_child(:expert_task_queue, fn -> - {:ok, _pid} = Project.Supervisor.start(config.project) - send(Expert, :engine_initialized) - end) + {:ok, _pid} = Project.Supervisor.start(config.project) + send(Expert, :engine_initialized) {:ok, response, new_state} end diff --git a/apps/expert/test/engine/build_test.exs b/apps/expert/test/engine/build_test.exs index 29dbaa70..961ff526 100644 --- a/apps/expert/test/engine/build_test.exs +++ b/apps/expert/test/engine/build_test.exs @@ -77,7 +77,7 @@ defmodule Engine.BuildTest do EngineApi.schedule_compile(project, true) assert_receive project_compiled(status: :success) - assert_receive project_progress(label: "Building " <> project_name) + assert_receive {:engine_progress_begin, _token, "Building " <> project_name, _} assert project_name == "project_metadata" end @@ -85,6 +85,7 @@ defmodule Engine.BuildTest do {:ok, project} = with_project(:project_metadata) EngineApi.schedule_compile(project, true) + assert_receive module_updated(name: ProjectMetadata, functions: functions) assert {:zero_arity, 0} in functions @@ -113,7 +114,7 @@ defmodule Engine.BuildTest do assert {:arity_1, 1} in functions assert {:arity_2, 2} in functions - assert_receive project_progress(label: "Building " <> project_name) + assert_receive {:engine_progress_begin, _token, "Building " <> project_name, _} assert project_name == "umbrella" end end diff --git a/apps/expert/test/expert/project/node_test.exs b/apps/expert/test/expert/project/node_test.exs index 1dd30372..1a3f4bc2 100644 --- a/apps/expert/test/expert/project/node_test.exs +++ b/apps/expert/test/expert/project/node_test.exs @@ -35,7 +35,7 @@ defmodule Expert.Project.NodeTest do old_pid = node_pid(project) :ok = EngineApi.stop(project) - assert_eventually Node.ping(node_name) == :pong, 1000 + assert_eventually(Node.ping(node_name) == :pong, 1000) new_pid = node_pid(project) assert is_pid(new_pid) @@ -48,7 +48,7 @@ defmodule Expert.Project.NodeTest do assert is_pid(supervisor_pid) Process.exit(supervisor_pid, :kill) - assert_eventually Node.ping(node_name) == :pong, 750 + assert_eventually(Node.ping(node_name) == :pong, 750) end defp node_pid(project) do diff --git a/apps/expert/test/expert/project/progress/state_test.exs b/apps/expert/test/expert/project/progress/state_test.exs index 4c87d4cf..90400ccc 100644 --- a/apps/expert/test/expert/project/progress/state_test.exs +++ b/apps/expert/test/expert/project/progress/state_test.exs @@ -1,72 +1,156 @@ defmodule Expert.Project.Progress.StateTest do + alias Expert.Configuration alias Expert.Project.Progress.State - alias Expert.Project.Progress.Value - import Forge.EngineApi.Messages import Forge.Test.Fixtures use ExUnit.Case, async: true + use Patch setup do project = project() + # Mock LSP interactions + # GenLSP.request returns nil for success, non-nil for error + patch(Expert, :get_lsp, fn -> self() end) + patch(GenLSP, :request, fn _, _ -> nil end) + patch(GenLSP, :notify, fn _, _ -> :ok end) + patch(Configuration, :client_supports?, fn :work_done_progress -> true end) {:ok, project: project} end - def progress(label, message \\ nil) do - project_progress(label: label, message: message) - end + describe "engine-initiated progress" do + test "register_engine_token adds token to active and sends begin notification", %{ + project: project + } do + state = State.new(project) + token = 12345 + title = "mix compile" - test "it should be able to add a begin event and put the new token", %{project: project} do - label = "mix deps.get" - state = project |> State.new() |> State.begin(progress(label)) + {:ok, new_state} = State.register_engine_token(state, token, title, []) - assert %Value{token: token, title: ^label, kind: :begin} = state.progress_by_label[label] - assert token != nil - end + assert MapSet.member?(new_state.active, token) + end - test "it should be able to add a report event use the begin event token", %{project: project} do - label = "mix compile" - state = project |> State.new() |> State.begin(progress(label)) + test "report works for registered engine token", %{project: project} do + state = State.new(project) + token = 12345 - previous_token = state.progress_by_label[label].token + {:ok, state} = State.register_engine_token(state, token, "mix compile", []) + {:ok, ^token, _state} = State.report(state, token, message: "Compiling...") - %{progress_by_label: progress_by_label} = - State.report(state, progress(label, "lib/my_module.ex")) + # Should not error + assert true + end - assert %Value{token: ^previous_token, message: "lib/my_module.ex", kind: :report} = - progress_by_label[label] - end + test "report returns noop for unknown engine token", %{project: project} do + state = State.new(project) + + assert {:noop, _state} = State.report(state, 99999, message: "test") + end - test "clear the token_by_label after received a complete event", %{project: project} do - state = project |> State.new() |> State.begin(progress("mix compile")) + test "complete removes engine token from active", %{project: project} do + state = State.new(project) + token = 12345 - %{progress_by_label: progress_by_label} = - State.complete(state, progress("mix compile", "in 2s")) + {:ok, state} = State.register_engine_token(state, token, "mix compile", []) + {:ok, new_state} = State.complete(state, token, []) - assert progress_by_label == %{} + refute MapSet.member?(new_state.active, token) + end + + test "complete returns error for unknown engine token", %{project: project} do + state = State.new(project) + + assert {:error, :unknown_token, _state} = State.complete(state, 99999, []) + end end - test "set the progress value to nil when there is no begin event", %{ - project: project - } do - state = project |> State.new() |> State.report(progress("mix compile")) - assert state.progress_by_label["mix compile"] == nil + describe "server-initiated progress" do + test "begin creates token and tracks in active set", %{project: project} do + state = State.new(project) + title = "Building" + + {:ok, token, new_state} = State.begin(state, title, []) + + assert is_integer(token) + assert MapSet.member?(new_state.active, token) + end + + test "report works for active token", %{project: project} do + state = State.new(project) + + {:ok, token, state} = State.begin(state, "Building", []) + {:ok, ^token, _state} = State.report(state, token, message: "In progress...") + + assert true + end + + test "report returns noop for unknown token", %{project: project} do + state = State.new(project) + + assert {:noop, _state} = State.report(state, 12345, message: "test") + end + + test "complete removes token from active set", %{project: project} do + state = State.new(project) + + {:ok, token, state} = State.begin(state, "Building", []) + {:ok, new_state} = State.complete(state, token, []) + + refute MapSet.member?(new_state.active, token) + end end - test "set the progress value to nil when a complete event received before the report", %{ - project: project - } do - label = "mix compile" + describe "ref-based progress" do + test "register tracks token with ref", %{project: project} do + state = State.new(project) + token = "client-token" + + {:ok, new_state} = State.register(state, token, ref: :initialize) + + assert MapSet.member?(new_state.active, token) + assert new_state.refs[:initialize] == token + end + + test "register without ref only tracks token", %{project: project} do + state = State.new(project) + token = "client-token" + + {:ok, new_state} = State.register(state, token, []) + + assert MapSet.member?(new_state.active, token) + assert new_state.refs == %{} + end + + test "report works for known ref", %{project: project} do + state = State.new(project) + token = "client-token" + + {:ok, state} = State.register(state, token, ref: :initialize) + {:ok, ^token, _state} = State.report(state, :initialize, message: "Loading...") + end + + test "report returns noop for unknown ref", %{project: project} do + state = State.new(project) + + assert {:noop, _state} = State.report(state, :unknown, message: "test") + end + + test "complete removes ref and token from tracking", %{project: project} do + state = State.new(project) + token = "client-token" + + {:ok, state} = State.register(state, token, ref: :initialize) + {:ok, new_state} = State.complete(state, :initialize, []) - state = - project - |> State.new() - |> State.begin(progress(label)) - |> State.complete(progress(label, "in 2s")) + refute Map.has_key?(new_state.refs, :initialize) + refute MapSet.member?(new_state.active, token) + end - %{progress_by_label: progress_by_label} = - State.report(state, progress(label, "lib/my_module.ex")) + test "complete returns error for unknown ref", %{project: project} do + state = State.new(project) - assert progress_by_label[label] == nil + assert {:error, :unknown_ref} = State.complete(state, :unknown, []) + end end end diff --git a/apps/expert/test/expert/project/progress/support_test.exs b/apps/expert/test/expert/project/progress/support_test.exs deleted file mode 100644 index ef1eabd0..00000000 --- a/apps/expert/test/expert/project/progress/support_test.exs +++ /dev/null @@ -1,33 +0,0 @@ -defmodule Expert.Project.Progress.SupportTest do - alias Expert.Project.Progress - - import Forge.EngineApi.Messages - import Forge.Test.Fixtures - - use ExUnit.Case - use Patch - use Progress.Support - - setup do - test_pid = self() - patch(Progress, :name, fn _ -> test_pid end) - :ok - end - - test "it should send begin/complete event and return the result" do - result = with_progress project(), "act", fn -> :ok end - - assert result == :ok - assert_received project_progress(label: "act", stage: :begin) - assert_received project_progress(label: "act", stage: :complete) - end - - test "it should send begin/complete event even there is an exception" do - assert_raise(Mix.Error, fn -> - with_progress project(), "start", fn -> raise Mix.Error, "can't start" end - end) - - assert_received project_progress(label: "start", stage: :begin) - assert_received project_progress(label: "start", stage: :complete) - end -end diff --git a/apps/expert/test/expert/project/progress_test.exs b/apps/expert/test/expert/project/progress_test.exs index 45725893..cec85b00 100644 --- a/apps/expert/test/expert/project/progress_test.exs +++ b/apps/expert/test/expert/project/progress_test.exs @@ -8,51 +8,44 @@ defmodule Expert.Project.ProgressTest do alias GenLSP.Structures import Forge.Test.Fixtures - import Forge.EngineApi.Messages use ExUnit.Case use Patch use DispatchFake use Forge.Test.EventualAssertions + @progress_message_types [ + :engine_progress_begin, + :engine_progress_report, + :engine_progress_complete + ] + setup do project = project() pid = start_supervised!({Project.Progress, project}) DispatchFake.start() - Engine.Dispatch.register_listener(pid, project_progress()) - Engine.Dispatch.register_listener(pid, percent_progress()) - - {:ok, project: project} - end - - def percent_begin(project, label, max) do - message = percent_progress(stage: :begin, label: label, max: max) - EngineApi.broadcast(project, message) - end - defp percent_report(project, label, delta, message \\ nil) do - message = percent_progress(stage: :report, label: label, message: message, delta: delta) - EngineApi.broadcast(project, message) - end - - defp percent_complete(project, label, message) do - message = percent_progress(stage: :complete, label: label, message: message) - EngineApi.broadcast(project, message) - end + for type <- @progress_message_types do + Engine.Dispatch.register_listener(pid, type) + end - def progress(stage, label, message \\ "") do - project_progress(label: label, message: message, stage: stage) + {:ok, project: project} end def with_patched_transport(_) do test = self() + patch(Expert, :get_lsp, fn -> self() end) + patch(GenLSP, :notify, fn _, message -> send(test, {:transport, message}) + :ok end) + # GenLSP.request returns nil for success patch(GenLSP, :request, fn _, message -> send(test, {:transport, message}) + nil end) :ok @@ -63,120 +56,242 @@ defmodule Expert.Project.ProgressTest do :ok end - describe "report the progress message" do - setup [:with_patched_transport] + describe "engine-initiated progress" do + setup [:with_patched_transport, :with_work_done_progress_support] - test "it should be able to send the report progress", %{project: project} do - patch(Configuration, :client_supports?, fn :work_done_progress -> true end) + test "it should send begin/report/complete notifications", %{project: project} do + # Engine generates token and broadcasts begin + engine_token = 12345 - begin_message = progress(:begin, "mix compile") - EngineApi.broadcast(project, begin_message) + EngineApi.broadcast(project, {:engine_progress_begin, engine_token, "mix compile", []}) assert_receive {:transport, %Requests.WindowWorkDoneProgressCreate{ - params: %Structures.WorkDoneProgressCreateParams{token: token} + params: %Structures.WorkDoneProgressCreateParams{token: ^engine_token} }} - assert_receive {:transport, %Notifications.DollarProgress{}} + assert_receive {:transport, %Notifications.DollarProgress{params: %{value: value}}} + assert value.kind == "begin" + assert value.title == "mix compile" - report_message = progress(:report, "mix compile", "lib/file.ex") - EngineApi.broadcast(project, report_message) + # Report progress + EngineApi.broadcast( + project, + {:engine_progress_report, engine_token, [message: "lib/file.ex"]} + ) assert_receive {:transport, %Notifications.DollarProgress{ - params: %Structures.ProgressParams{token: ^token, value: value} + params: %Structures.ProgressParams{token: ^engine_token, value: value} }} assert value.kind == "report" assert value.message == "lib/file.ex" - assert value.percentage == nil - assert value.cancellable == nil + + # Complete progress + EngineApi.broadcast( + project, + {:engine_progress_complete, engine_token, [message: "Done"]} + ) + + assert_receive {:transport, + %Notifications.DollarProgress{ + params: %Structures.ProgressParams{token: ^engine_token, value: value} + }} + + assert value.kind == "end" + assert value.message == "Done" + end + + test "it should support percentage updates", %{project: project} do + engine_token = 67890 + + EngineApi.broadcast( + project, + {:engine_progress_begin, engine_token, "indexing", [percentage: 0]} + ) + + assert_receive {:transport, + %Requests.WindowWorkDoneProgressCreate{params: %{token: ^engine_token}}} + + assert_receive {:transport, %Notifications.DollarProgress{params: %{value: value}}} + + assert value.kind == "begin" + assert value.title == "indexing" + assert value.percentage == 0 + + EngineApi.broadcast( + project, + {:engine_progress_report, engine_token, [message: "Processing...", percentage: 50]} + ) + + assert_receive {:transport, + %Notifications.DollarProgress{ + params: %Structures.ProgressParams{token: ^engine_token, value: value} + }} + + assert value.kind == "report" + assert value.percentage == 50 + assert value.message == "Processing..." + + EngineApi.broadcast( + project, + {:engine_progress_complete, engine_token, [message: "Complete"]} + ) + + assert_receive {:transport, + %Notifications.DollarProgress{params: %{token: ^engine_token, value: value}}} + + assert value.kind == "end" + assert value.message == "Complete" end test "it should write nothing when the client does not support work done", %{project: project} do patch(Configuration, :client_supports?, fn :work_done_progress -> false end) - begin_message = progress(:begin, "mix compile") - EngineApi.broadcast(project, begin_message) + EngineApi.broadcast(project, {:engine_progress_begin, 11111, "mix compile", []}) refute_receive {:transport, %Requests.WindowWorkDoneProgressCreate{params: %{}}} end + + test "it ignores updates for unknown tokens", %{project: project} do + # Report/complete without a matching begin should not crash + EngineApi.broadcast(project, {:engine_progress_report, 99999, [message: "test"]}) + EngineApi.broadcast(project, {:engine_progress_complete, 99999, []}) + + # Should not receive any notifications for unknown progress + refute_receive {:transport, %Notifications.DollarProgress{}} + end end - describe "reporting a percentage progress" do + describe "manual progress API" do setup [:with_patched_transport, :with_work_done_progress_support] - test "it should be able to increment the percentage", %{project: project} do - percent_begin(project, "indexing", 400) - - assert_receive {:transport, %Requests.WindowWorkDoneProgressCreate{params: %{token: token}}} - assert_receive {:transport, %Notifications.DollarProgress{} = progress} + test "register/3 registers a client-initiated progress with ref", %{project: project} do + client_token = "client-token-123" - assert progress.params.value.kind == "begin" - assert progress.params.value.title == "indexing" - assert progress.params.value.percentage == 0 + :ok = Project.Progress.register(project, client_token, ref: :initialize) - percent_report(project, "indexing", 100) + # Report using the ref (cast, returns :ok immediately) + :ok = Project.Progress.report(project, :initialize, message: "Loading...") assert_receive {:transport, %Notifications.DollarProgress{ - params: %Structures.ProgressParams{token: ^token, value: value} + params: %{token: ^client_token, value: value} }} assert value.kind == "report" - assert value.percentage == 25 - assert value.message == nil + assert value.message == "Loading..." - percent_report(project, "indexing", 260, "Almost done") + # End using the ref + :ok = Project.Progress.complete(project, :initialize, message: "Done") assert_receive {:transport, - %Notifications.DollarProgress{params: %{token: ^token, value: value}}} + %Notifications.DollarProgress{ + params: %{token: ^client_token, value: value} + }} + + assert value.kind == "end" + assert value.message == "Done" + end - assert value.percentage == 90 - assert value.message == "Almost done" + test "begin/3 starts server-initiated progress and returns token", %{project: project} do + {:ok, token} = Project.Progress.begin(project, "Building", message: "Starting...") - percent_complete(project, "indexing", "Indexing Complete") + assert is_integer(token) + + assert_receive {:transport, + %Requests.WindowWorkDoneProgressCreate{params: %{token: ^token}}} assert_receive {:transport, %Notifications.DollarProgress{params: %{token: ^token, value: value}}} - assert value.kind == "end" - assert value.message == "Indexing Complete" + assert value.kind == "begin" + assert value.title == "Building" + assert value.message == "Starting..." end - test "it caps the percentage at 100", %{project: project} do - percent_begin(project, "indexing", 100) - percent_report(project, "indexing", 1000) + test "report/3 with token sends progress update", %{project: project} do + {:ok, token} = Project.Progress.begin(project, "Processing") + + # Drain begin notifications + assert_receive {:transport, %Requests.WindowWorkDoneProgressCreate{}} assert_receive {:transport, %Notifications.DollarProgress{params: %{value: %{kind: "begin"}}}} - assert_receive {:transport, %Notifications.DollarProgress{params: %{value: value}}} + # report is now a cast, returns :ok immediately + :ok = Project.Progress.report(project, token, message: "50% complete", percentage: 50) + + assert_receive {:transport, + %Notifications.DollarProgress{params: %{token: ^token, value: value}}} + assert value.kind == "report" - assert value.percentage == 100 + assert value.message == "50% complete" + assert value.percentage == 50 end - test "it only allows the percentage to grow", %{project: project} do - percent_begin(project, "indexing", 100) + test "report/3 with unknown ref is a no-op", %{project: project} do + # report is a cast, always returns :ok (unknown refs are silently ignored with warning log) + result = Project.Progress.report(project, :nonexistent_ref, message: "test") + assert result == :ok + end + + test "complete/3 with token completes progress", %{project: project} do + {:ok, token} = Project.Progress.begin(project, "Task") + + # Drain begin notifications + assert_receive {:transport, %Requests.WindowWorkDoneProgressCreate{}} assert_receive {:transport, %Notifications.DollarProgress{params: %{value: %{kind: "begin"}}}} - percent_report(project, "indexing", 10) + :ok = Project.Progress.complete(project, token, message: "Complete!") - assert_receive {:transport, %Notifications.DollarProgress{params: %{value: value}}} - assert value.kind == "report" - assert value.percentage == 10 + assert_receive {:transport, + %Notifications.DollarProgress{params: %{token: ^token, value: value}}} - percent_report(project, "indexing", -10) - assert_receive {:transport, %Notifications.DollarProgress{params: %{value: value}}} - assert value.kind == "report" - assert value.percentage == 10 + assert value.kind == "end" + assert value.message == "Complete!" + end - percent_report(project, "indexing", 5) - assert_receive {:transport, %Notifications.DollarProgress{params: %{value: value}}} - assert value.kind == "report" - assert value.percentage == 15 + test "complete/3 with unknown ref returns :ok (no-op)", %{project: project} do + result = Project.Progress.complete(project, :nonexistent_ref, message: "test") + assert result == :ok + end + + test "full manual workflow with server-initiated progress", %{project: project} do + # Begin + {:ok, token} = Project.Progress.begin(project, "Indexing", percentage: 0) + + assert_receive {:transport, + %Requests.WindowWorkDoneProgressCreate{params: %{token: ^token}}} + + assert_receive {:transport, + %Notifications.DollarProgress{params: %{token: ^token, value: value}}} + + assert value.kind == "begin" + assert value.percentage == 0 + + # Report multiple updates (cast, returns :ok) + for i <- [25, 50, 75] do + :ok = Project.Progress.report(project, token, message: "#{i}%", percentage: i) + + assert_receive {:transport, + %Notifications.DollarProgress{params: %{token: ^token, value: value}}} + + assert value.kind == "report" + assert value.percentage == i + end + + # End + :ok = Project.Progress.complete(project, token, message: "Indexed!") + + assert_receive {:transport, + %Notifications.DollarProgress{params: %{token: ^token, value: value}}} + + assert value.kind == "end" + assert value.message == "Indexed!" end end end diff --git a/apps/forge/lib/forge/engine_api/messages.ex b/apps/forge/lib/forge/engine_api/messages.ex index f1d83b2d..1adfb96a 100644 --- a/apps/forge/lib/forge/engine_api/messages.ex +++ b/apps/forge/lib/forge/engine_api/messages.ex @@ -33,10 +33,6 @@ defmodule Forge.EngineApi.Messages do defrecord :file_diagnostics, project: nil, build_number: 0, uri: nil, diagnostics: [] - defrecord :project_progress, label: nil, message: nil, stage: :report - - defrecord :percent_progress, label: nil, message: nil, stage: :report, max: 0, delta: 0 - defrecord :struct_discovered, module: nil, fields: [] defrecord :project_index_ready, project: nil @@ -116,13 +112,6 @@ defmodule Forge.EngineApi.Messages do diagnostics: diagnostics() ) - @type project_progress :: - record(:project_progress, - label: String.t(), - message: String.t() | integer(), - stage: :prepare | :begin | :report | :complete - ) - @type struct_discovered :: record(:struct_discovered, module: module(), fields: field_list()) @type project_index_ready :: record(:project_index_ready, project: Forge.Project.t()) diff --git a/apps/forge/lib/forge/namespace/transform/beams.ex b/apps/forge/lib/forge/namespace/transform/beams.ex index ca44c363..418fea3c 100644 --- a/apps/forge/lib/forge/namespace/transform/beams.ex +++ b/apps/forge/lib/forge/namespace/transform/beams.ex @@ -46,21 +46,40 @@ defmodule Forge.Namespace.Transform.Beams do defp changed?(same, same), do: false defp changed?(_, _), do: true - defp block_until_done(same, same) do - Mix.Shell.IO.info("\n done") - end + defp block_until_done(same, same, last_write_time \\ nil) + + defp block_until_done(same, same, _last_write_time), do: Mix.Shell.IO.info("\n done") - defp block_until_done(current, max) do + defp block_until_done(current, max, last_write_time) do receive do :progress -> :ok end current = current + 1 + + last_write_time = log_completion_debounced(current, max, last_write_time) + + block_until_done(current, max, last_write_time) + end + + defp log_completion_debounced(current, max, last_write_time) when is_integer(last_write_time) do + now = :erlang.monotonic_time(:millisecond) + + if now - last_write_time >= 66 do + IO.write("\r") + IO.write("Applying namespace: #{format_percent(current, max)} complete") + + now + else + last_write_time + end + end + + defp log_completion_debounced(current, max, _last_write_time) do IO.write("\r") - percent_complete = format_percent(current, max) + IO.write("Applying namespace: #{format_percent(current, max)} complete") - IO.write(" Applying namespace: #{percent_complete} complete") - block_until_done(current, max) + :erlang.monotonic_time(:millisecond) end defp apply_and_update_progress(beam_file, caller) do @@ -109,7 +128,7 @@ defmodule Forge.Namespace.Transform.Beams do defp format_percent(current, max) do int_val = (current / max * 100) - |> round() + |> floor() |> Integer.to_string() String.pad_leading("#{int_val}%", 4) From f5efa5b4cf5d9c87f96e1ca6bb94f63b825c4f03 Mon Sep 17 00:00:00 2001 From: Moosieus Date: Sat, 6 Dec 2025 16:35:30 -0500 Subject: [PATCH 02/17] Make progress module stateless --- apps/engine/lib/engine/build/state.ex | 4 +- apps/engine/lib/engine/compilation/tracer.ex | 11 +- apps/engine/lib/engine/dispatch.ex | 31 +- apps/engine/lib/engine/progress.ex | 35 +-- apps/engine/test/engine/progress_test.exs | 45 ++- apps/expert/lib/expert/engine_node.ex | 3 +- apps/expert/lib/expert/progress.ex | 258 +++++++++++++++ apps/expert/lib/expert/project/node.ex | 5 +- apps/expert/lib/expert/project/progress.ex | 231 -------------- .../lib/expert/project/progress/state.ex | 206 ------------ apps/expert/lib/expert/project/supervisor.ex | 2 - apps/expert/test/expert/progress_test.exs | 223 +++++++++++++ .../expert/project/progress/state_test.exs | 156 --------- .../test/expert/project/progress_test.exs | 297 ------------------ 14 files changed, 556 insertions(+), 951 deletions(-) create mode 100644 apps/expert/lib/expert/progress.ex delete mode 100644 apps/expert/lib/expert/project/progress.ex delete mode 100644 apps/expert/lib/expert/project/progress/state.ex create mode 100644 apps/expert/test/expert/progress_test.exs delete mode 100644 apps/expert/test/expert/project/progress/state_test.exs delete mode 100644 apps/expert/test/expert/project/progress_test.exs diff --git a/apps/engine/lib/engine/build/state.ex b/apps/engine/lib/engine/build/state.ex index ad65f3ff..0d2e395e 100644 --- a/apps/engine/lib/engine/build/state.ex +++ b/apps/engine/lib/engine/build/state.ex @@ -1,6 +1,7 @@ defmodule Engine.Build.State do alias Elixir.Features alias Engine.Build + alias Engine.Compilation.Tracer alias Engine.Plugin alias Engine.Progress alias Forge.Document @@ -82,7 +83,7 @@ defmodule Engine.Build.State do project = state.project Build.with_lock(fn -> - {:ok, work_done_token} = Progress.begin(building_label(project)) + work_done_token = Progress.begin(building_label(project)) try do compile_requested_message = @@ -123,6 +124,7 @@ defmodule Engine.Build.State do Engine.broadcast(diagnostics_message) Plugin.diagnose(project, state.build_number) after + Tracer.clear_build_token() Progress.complete(work_done_token) end end) diff --git a/apps/engine/lib/engine/compilation/tracer.ex b/apps/engine/lib/engine/compilation/tracer.ex index f29c3ff3..817ef778 100644 --- a/apps/engine/lib/engine/compilation/tracer.ex +++ b/apps/engine/lib/engine/compilation/tracer.ex @@ -57,7 +57,10 @@ defmodule Engine.Compilation.Tracer do defp maybe_report_progress(file) do if Path.extname(file) == ".ex" do - Progress.report(:build, message: progress_message(file)) + case get_build_token() do + nil -> :noop + token -> Progress.report(token, message: progress_message(file)) + end end end @@ -72,4 +75,10 @@ defmodule Engine.Compilation.Tracer do "compiling: " <> Path.join([base_dir, "...", file_name]) end + + # Kludgy persistent_term for build token access + @build_token_key {__MODULE__, :build_token} + def set_build_token(token), do: :persistent_term.put(@build_token_key, token) + def clear_build_token, do: :persistent_term.erase(@build_token_key) + defp get_build_token, do: :persistent_term.get(@build_token_key, nil) end diff --git a/apps/engine/lib/engine/dispatch.ex b/apps/engine/lib/engine/dispatch.ex index 8b0e7071..5ef55432 100644 --- a/apps/engine/lib/engine/dispatch.ex +++ b/apps/engine/lib/engine/dispatch.ex @@ -13,12 +13,6 @@ defmodule Engine.Dispatch do @handlers [PubSub, Handlers.Indexing] - @progress_message_types [ - :engine_progress_begin, - :engine_progress_report, - :engine_progress_complete - ] - # public API @doc """ @@ -51,15 +45,10 @@ defmodule Engine.Dispatch do # GenServer callbacks - def start_link(opts) do + def start_link(_opts) do case :gen_event.start_link(name()) do {:ok, pid} = success -> Enum.each(@handlers, &:gen_event.add_handler(pid, &1, [])) - - if opts[:progress] do - register_progress_listener() - end - success error -> @@ -78,13 +67,21 @@ defmodule Engine.Dispatch do {:local, __MODULE__} end - defp register_progress_listener do - register_listener(progress_pid(), @progress_message_types) + @doc """ + :rpc.call to the server node. + """ + def rpc_call(module, function, args) do + project = Engine.get_project() + manager_node = Project.manager_node_name(project) + :rpc.call(manager_node, module, function, args) end - defp progress_pid do + @doc """ + :rpc.cast to the server node. + """ + def rpc_cast(module, function, args) do project = Engine.get_project() - manager_node_name = Project.manager_node_name(project) - :rpc.call(manager_node_name, Expert.Project.Progress, :whereis, [project]) + manager_node = Project.manager_node_name(project) + :rpc.cast(manager_node, module, function, args) end end diff --git a/apps/engine/lib/engine/progress.ex b/apps/engine/lib/engine/progress.ex index 50191a2f..42a9b3c8 100644 --- a/apps/engine/lib/engine/progress.ex +++ b/apps/engine/lib/engine/progress.ex @@ -3,6 +3,8 @@ defmodule Engine.Progress do LSP progress reporting for engine operations. """ + alias Engine.Dispatch + @type work_result :: {:done, term()} | {:done, term(), String.t()} | {:cancel, term()} @type work_fn :: (integer() -> work_result()) @@ -58,51 +60,48 @@ defmodule Engine.Progress do @doc """ Manually begins a progress sequence with the given title. - Generates a token internally and returns it for use with subsequent `report/2` and `complete/2` calls. + Generates a token internally and returns it for use with subsequent + `report/2` and `complete/2` calls. ## Options - `:message` - Initial status message - `:percentage` - Initial percentage 0-100 - `:cancellable` - Whether the client can cancel - - `:ref` - Atom ref to associate with this progress (e.g. `:build`). - Allows using `report/2` and `complete/2` with the ref instead of the token. """ @spec begin(String.t(), keyword()) :: integer() def begin(title, opts \\ []) do - token = System.unique_integer([:positive]) - Engine.broadcast({:engine_progress_begin, token, title, opts}) - token + # TODO: BAD. + case Dispatch.rpc_call(Expert.Progress, :begin, [title, opts]) do + {:ok, token} -> token + _ -> -1 + end end @doc """ Reports progress for an in-progress operation. - Accepts either a token (integer returned by `begin/2`) or a ref (atom registered - via `begin/2` with `:ref` option, or `:initialize` for client-initiated progress). - ## Options - `:message` - Status message to display - `:percentage` - Progress percentage 0-100 """ - @spec report(integer() | atom(), keyword()) :: :ok - def report(token_or_ref, updates \\ []) do - Engine.broadcast({:engine_progress_report, token_or_ref, updates}) + @spec report(integer(), keyword()) :: :ok + def report(token, updates \\ []) when is_integer(token) do + Dispatch.rpc_cast(Expert.Progress, :report, [token, updates]) + :ok end @doc """ Completes a progress sequence. - Accepts either a token (integer returned by `begin/2`) or a ref (atom registered - via `begin/2` with `:ref` option, or `:initialize` for client-initiated progress). - ## Options - `:message` - Final completion message """ - @spec complete(integer() | atom(), keyword()) :: :ok - def complete(token_or_ref, opts \\ []) do - Engine.broadcast({:engine_progress_complete, token_or_ref, opts}) + @spec complete(integer(), keyword()) :: :ok + def complete(token, opts \\ []) when is_integer(token) do + Dispatch.rpc_cast(Expert.Progress, :complete, [token, opts]) + :ok end end diff --git a/apps/engine/test/engine/progress_test.exs b/apps/engine/test/engine/progress_test.exs index 60ae22e7..37a3a150 100644 --- a/apps/engine/test/engine/progress_test.exs +++ b/apps/engine/test/engine/progress_test.exs @@ -2,11 +2,25 @@ defmodule Engine.ProgressTest do use ExUnit.Case use Patch + alias Engine.Dispatch alias Engine.Progress setup do test_pid = self() - patch(Engine.Api.Proxy, :broadcast, &send(test_pid, &1)) + + # Mock rpc_call for begin - returns {:ok, token} + patch(Dispatch, :rpc_call, fn Expert.Progress, :begin, [title, opts] -> + token = System.unique_integer([:positive]) + send(test_pid, {:begin, token, title, opts}) + {:ok, token} + end) + + # Mock rpc_cast for report and complete + patch(Dispatch, :rpc_cast, fn Expert.Progress, function, args -> + send(test_pid, {function, args}) + true + end) + :ok end @@ -14,16 +28,16 @@ defmodule Engine.ProgressTest do result = Progress.with_progress("foo", fn _token -> {:done, :ok} end) assert result == :ok - assert_received {:engine_progress_begin, token, "foo", []} when is_integer(token) - assert_received {:engine_progress_complete, ^token, []} + assert_received {:begin, token, "foo", []} when is_integer(token) + assert_received {:complete, [^token, []]} end test "it should send begin/complete event with final message" do result = Progress.with_progress("bar", fn _token -> {:done, :success, "Completed!"} end) assert result == :success - assert_received {:engine_progress_begin, token, "bar", []} when is_integer(token) - assert_received {:engine_progress_complete, ^token, [message: "Completed!"]} + assert_received {:begin, token, "bar", []} when is_integer(token) + assert_received {:complete, [^token, [message: "Completed!"]]} end test "it should send report events when Progress.report is called" do @@ -35,13 +49,10 @@ defmodule Engine.ProgressTest do end) assert result == :indexed - assert_received {:engine_progress_begin, token, "indexing", []} when is_integer(token) - assert_received {:engine_progress_report, ^token, [message: "Processing file 1..."]} - - assert_received {:engine_progress_report, ^token, - [message: "Processing file 2...", percentage: 50]} - - assert_received {:engine_progress_complete, ^token, []} + assert_received {:begin, token, "indexing", []} when is_integer(token) + assert_received {:report, [^token, [message: "Processing file 1..."]]} + assert_received {:report, [^token, [message: "Processing file 2...", percentage: 50]]} + assert_received {:complete, [^token, []]} end test "it should send begin/complete event even when there is an exception" do @@ -49,16 +60,16 @@ defmodule Engine.ProgressTest do Progress.with_progress("compile", fn _token -> raise Mix.Error, "can't compile" end) end) - assert_received {:engine_progress_begin, token, "compile", []} when is_integer(token) - assert_received {:engine_progress_complete, ^token, [message: "Error: can't compile"]} + assert_received {:begin, token, "compile", []} when is_integer(token) + assert_received {:complete, [^token, [message: "Error: can't compile"]]} end test "it should handle cancel result" do result = Progress.with_progress("cancellable", fn _token -> {:cancel, :cancelled} end) assert result == :cancelled - assert_received {:engine_progress_begin, token, "cancellable", []} when is_integer(token) - assert_received {:engine_progress_complete, ^token, [message: "Cancelled"]} + assert_received {:begin, token, "cancellable", []} when is_integer(token) + assert_received {:complete, [^token, [message: "Cancelled"]]} end test "it should pass through initial options" do @@ -70,7 +81,7 @@ defmodule Engine.ProgressTest do percentage: 0 ) - assert_received {:engine_progress_begin, _token, "with_opts", opts} + assert_received {:begin, _token, "with_opts", opts} assert opts[:message] == "Starting..." assert opts[:percentage] == 0 end diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index d1c72951..fc454f54 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -220,8 +220,7 @@ defmodule Expert.EngineNode do GenLSP.info(lsp, "Finding or building engine for project #{project_name}") - Expert.Project.Progress.with_server_progress( - project, + Expert.Progress.with_progress( "Building engine for #{project_name}", fn _token -> result = diff --git a/apps/expert/lib/expert/progress.ex b/apps/expert/lib/expert/progress.ex new file mode 100644 index 00000000..ecbf19b3 --- /dev/null +++ b/apps/expert/lib/expert/progress.ex @@ -0,0 +1,258 @@ +defmodule Expert.Progress do + @moduledoc """ + Stateless progress reporting for LSP work-done progress. + + This module provides a simple API for reporting progress to the language client. + It is stateless - callers are responsible for managing their own tokens. When a + request handler process dies (e.g., due to cancellation), any tokens it held + naturally go away without explicit cleanup. + + ## Server-initiated progress + + {:ok, token} = Progress.begin("Building project") + Progress.report(token, message: "Compiling...") + Progress.complete(token, message: "Done") + + Or use the convenience wrapper: + + Progress.with_progress("Building", fn token -> + Progress.report(token, message: "Working...") + {:done, result} + end) + + ## Client-initiated progress + + When the client provides a workDoneToken with a request: + + Progress.with_client_progress(client_token, fn token -> + Progress.report(token, message: "Processing...") + {:done, result} + end) + """ + + alias Expert.Configuration + alias Expert.Protocol.Id + alias GenLSP.{Notifications, Requests, Structures} + + require Logger + + @type token :: integer() | String.t() + @type work_result :: {:done, term()} | {:done, term(), String.t()} | {:cancel, term()} + @type work_fn :: (token() -> work_result()) + + defguardp is_token(token) when is_binary(token) or is_integer(token) + + @doc """ + Begins server-initiated progress. + + Generates a token, requests the client create the progress indicator, + and sends the begin notification. + + ## Options + + * `:message` - Initial status message (optional) + * `:percentage` - Initial percentage 0-100 (optional) + * `:cancellable` - Whether the client can cancel (default: false) + + ## Examples + + {:ok, token} = Progress.begin("Building project") + {:ok, token} = Progress.begin("Indexing", message: "Starting...", percentage: 0) + """ + @spec begin(String.t(), keyword()) :: {:ok, integer()} | {:error, :rejected} + def begin(title, opts \\ []) do + opts = Keyword.validate!(opts, [:message, :percentage, :cancellable]) + token = System.unique_integer([:positive]) + + if Configuration.client_supports?(:work_done_progress) do + case request_work_done_progress(token) do + :ok -> + notify_begin(token, title, opts) + {:ok, token} + + {:error, reason} -> + Logger.warning("Client rejected progress token: #{inspect(reason)}") + {:error, :rejected} + end + else + {:ok, -1} + end + end + + @doc """ + Reports progress update. + + ## Options + + * `:message` - Status message (optional) + * `:percentage` - Percentage 0-100 (optional) + + ## Examples + + Progress.report(token, message: "Processing file 1...") + Progress.report(token, message: "Halfway there", percentage: 50) + """ + @spec report(token(), keyword()) :: :ok + def report(token, opts \\ []) + + def report(-1, _opts), do: :ok + + def report(token, opts) when is_token(token) do + notify_report(token, opts) + + :ok + end + + @doc """ + Ends a progress sequence. + + ## Options + + * `:message` - Final completion message (optional) + + ## Examples + + Progress.complete(token) + Progress.complete(token, message: "Build complete") + """ + @spec complete(token(), keyword()) :: :ok + def complete(token, opts \\ []) + + def complete(-1, _opts), do: :ok + + def complete(token, opts) when is_token(token) do + notify_end(token, opts) + :ok + end + + @doc """ + Wraps a function with server-initiated progress reporting. + + The function receives the progress token and can call `Progress.report/2` directly. + + ## Return values + + * `{:done, result}` - Operation completed successfully + * `{:done, result, message}` - Completed with a final message + * `{:cancel, result}` - Operation was cancelled + + ## Options + + * `:message` - Initial status message (optional) + * `:percentage` - Initial percentage 0-100 (optional) + * `:cancellable` - Whether the client can cancel (default: false) + + ## Examples + + Progress.with_progress("Building", fn token -> + Progress.report(token, message: "Compiling...") + {:done, :ok, "Build complete"} + end) + """ + @spec with_progress(String.t(), work_fn(), keyword()) :: term() + def with_progress(title, func, opts \\ []) when is_function(func, 1) do + case begin(title, opts) do + {:ok, token} -> + run_work(token, func) + + {:error, :rejected} -> + # Client rejected the progress token, but we still run the work + # Just pass a dummy token that won't send notifications + case func.(0) do + {:done, result} -> result + {:done, result, _message} -> result + {:cancel, result} -> result + end + end + end + + @doc """ + Wraps a function with client-initiated progress reporting. + + Similar to `with_progress/3` but uses a token provided by the client. + """ + @spec with_client_progress(token(), work_fn()) :: term() + def with_client_progress(token, func) when is_function(func, 1) and is_token(token) do + run_work(token, func) + end + + defp run_work(token, func) do + try do + case func.(token) do + {:done, result} -> + complete(token) + result + + {:done, result, message} -> + complete(token, message: message) + result + + {:cancel, result} -> + complete(token, message: "Cancelled") + result + end + rescue + e -> + complete(token, message: "Error: #{Exception.message(e)}") + reraise e, __STACKTRACE__ + end + end + + defp request_work_done_progress(token) do + Expert.get_lsp() + |> GenLSP.request(%Requests.WindowWorkDoneProgressCreate{ + id: Id.next(), + params: %Structures.WorkDoneProgressCreateParams{token: token} + }) + |> case do + nil -> :ok + error -> {:error, error} + end + end + + defp notify_begin(token, title, opts) do + lsp = Expert.get_lsp() + + GenLSP.notify(lsp, %Notifications.DollarProgress{ + params: %Structures.ProgressParams{ + token: token, + value: %Structures.WorkDoneProgressBegin{ + kind: "begin", + title: title, + message: Keyword.get(opts, :message), + percentage: Keyword.get(opts, :percentage), + cancellable: Keyword.get(opts, :cancellable) + } + } + }) + end + + defp notify_report(token, updates) do + lsp = Expert.get_lsp() + + GenLSP.notify(lsp, %Notifications.DollarProgress{ + params: %Structures.ProgressParams{ + token: token, + value: %Structures.WorkDoneProgressReport{ + kind: "report", + message: Keyword.get(updates, :message), + percentage: Keyword.get(updates, :percentage) + } + } + }) + end + + defp notify_end(token, opts) do + lsp = Expert.get_lsp() + + GenLSP.notify(lsp, %Notifications.DollarProgress{ + params: %Structures.ProgressParams{ + token: token, + value: %Structures.WorkDoneProgressEnd{ + kind: "end", + message: Keyword.get(opts, :message) + } + } + }) + end +end diff --git a/apps/expert/lib/expert/project/node.ex b/apps/expert/lib/expert/project/node.ex index 0dcde98d..092e083f 100644 --- a/apps/expert/lib/expert/project/node.ex +++ b/apps/expert/lib/expert/project/node.ex @@ -15,7 +15,7 @@ defmodule Expert.Project.Node do alias Expert.EngineApi alias Expert.EngineNode - alias Expert.Project.Progress + alias Expert.Progress require Logger @@ -50,8 +50,7 @@ defmodule Expert.Project.Node do @impl GenServer def init(%Project{} = project) do - project - |> Progress.with_server_progress("Project Node", fn _token -> + Progress.with_progress("Project Node", fn _token -> result = start_node(project) {:done, result, "Project node started"} diff --git a/apps/expert/lib/expert/project/progress.ex b/apps/expert/lib/expert/project/progress.ex deleted file mode 100644 index dba8e335..00000000 --- a/apps/expert/lib/expert/project/progress.ex +++ /dev/null @@ -1,231 +0,0 @@ -defmodule Expert.Project.Progress do - alias Expert.Project.Progress.State - alias Forge.Project - - use GenServer - - @type work_result :: {:done, term()} | {:done, term(), String.t()} | {:cancel, term()} - @type work_fn :: (integer() | String.t() -> work_result()) - - defguardp is_token(token) when is_binary(token) or is_integer(token) - - @doc """ - Wraps a function with server-initiated progress reporting. - - The function receives the progress token and can call `Progress.report/3` directly: - - Progress.with_server_progress(project, "Building", fn token -> - Progress.report(project, token, message: "Compiling...") - compile() - {:done, :ok, "Build complete"} - end) - - ## Options - - * `:message` - Initial status message (optional) - * `:percentage` - Initial percentage 0-100 (optional) - * `:cancellable` - Whether the client can cancel (default: false) - """ - @spec with_server_progress(Project.t(), String.t(), work_fn(), keyword()) :: term() - def with_server_progress(project, title, func, opts \\ []) when is_function(func, 1) do - opts = Keyword.validate!(opts, [:message, :percentage, :cancellable]) - {:ok, token} = begin(project, title, opts) - run_work(project, token, func) - end - - @doc """ - Wraps a function with client-initiated progress reporting, and closes it on completion. - - The function receives the progress token and can call `Progress.report/3` directly: - - Progress.with_client_progress(project, client_token, fn token -> - Progress.report(project, token, message: "Compiling...") - compile() - {:done, :ok, "Build complete"} - end) - """ - @spec with_client_progress(Project.t(), integer() | String.t(), work_fn()) :: term() - def with_client_progress(project, client_token, func) - when is_function(func, 1) and is_token(client_token) do - :ok = register(project, client_token) - run_work(project, client_token, func) - end - - defp run_work(project, token, func) do - try do - case func.(token) do - {:done, result} -> - complete(project, token, []) - result - - {:done, result, message} -> - complete(project, token, message: message) - result - - {:cancel, result} -> - complete(project, token, message: "Cancelled") - result - end - rescue - e -> - complete(project, token, message: "Error: #{Exception.message(e)}") - reraise e, __STACKTRACE__ - end - end - - @doc """ - Manually registers a client-initiated progress token. - - ## Options - - * `:ref` - An atom to use as a stable identifier for this progress (optional). - - ## Examples - - :ok = Progress.register(project, client_work_token, ref: :initialize) - """ - @spec register(Project.t(), integer() | String.t(), keyword()) :: :ok - def register(project, client_token, opts \\ []) do - GenServer.call(name(project), {:register, client_token, opts}) - end - - @doc """ - Manually begins a server-initiated progress. - - ## Options - - * `:message` - Initial status message (optional) - * `:percentage` - Initial percentage 0-100 (optional) - * `:cancellable` - Whether the client can cancel (default: false) - - ## Examples - - {:ok, work_done_token} = Progress.begin(project, "Building", message: "Starting...") - """ - @spec begin(Project.t(), String.t(), keyword()) :: {:ok, integer()} | {:error, :rejected} - def begin(project, title, opts \\ []) do - GenServer.call(name(project), {:begin, title, opts}) - end - - @doc """ - Reports progress update (fire-and-forget). - - This is a cast operation - it returns immediately without waiting for confirmation. - If the token/ref doesn't exist, the update is silently ignored with a warning log. - - ## Options - - * `:message` - Status message (optional) - * `:percentage` - Percentage 0-100 (optional) - - ## Examples - - Progress.report(project, :initialize, message: "Loading...") - Progress.report(project, work_done_token, message: "Processing...", percentage: 50) - """ - @spec report(Project.t(), atom() | integer() | String.t(), keyword()) :: :ok - def report(project, token_or_ref, opts \\ []) do - GenServer.cast(name(project), {:report, token_or_ref, opts}) - end - - @doc """ - Manually ends a progress token. - - ## Options - - * `:message` - Final message, typically indicating some outcome (optional). - - ## Examples - - :ok = Progress.complete(project, work_done_token, message: "Done!") - :ok = Progress.complete(project, :initialize, message: "Ready") - """ - @spec complete(Project.t(), atom() | integer() | String.t(), keyword()) :: :ok - def complete(project, token_or_ref, opts \\ []) do - GenServer.call(name(project), {:end, token_or_ref, opts}) - end - - # GenServer API - - def start_link(%Project{} = project) do - GenServer.start_link(__MODULE__, project, name: name(project)) - end - - def child_spec(%Project{} = project) do - %{ - id: {__MODULE__, Project.name(project)}, - start: {__MODULE__, :start_link, [project]} - } - end - - @impl GenServer - def init(project) do - state = State.new(project) - - {:ok, state} - end - - @impl GenServer - def handle_call({:register, token, opts}, _from, %State{} = state) when is_token(token) do - {:ok, new_state} = State.register(state, token, opts) - {:reply, :ok, new_state} - end - - def handle_call({:begin, title, opts}, _from, %State{} = state) do - case State.begin(state, title, opts) do - {:ok, token, new_state} -> {:reply, {:ok, token}, new_state} - {:error, :rejected} -> {:reply, {:error, :rejected}, state} - end - end - - def handle_call({:end, token, opts}, _from, %State{} = state) when is_token(token) do - case State.complete(state, token, opts) do - {:ok, new_state} -> {:reply, :ok, new_state} - {:error, :unknown_token, state} -> {:reply, :ok, state} - end - end - - def handle_call({:end, ref, opts}, _from, %State{} = state) when is_atom(ref) do - case State.complete(state, ref, opts) do - {:ok, new_state} -> {:reply, :ok, new_state} - {:error, :unknown_ref} -> {:reply, :ok, state} - end - end - - @impl GenServer - def handle_cast({:report, token_or_ref, opts}, %State{} = state) do - case State.report(state, token_or_ref, opts) do - {:ok, _token, new_state} -> {:noreply, new_state} - {:noop, state} -> {:noreply, state} - end - end - - # Engine Node handlers - - @impl true - def handle_info({:engine_progress_begin, token, title, opts}, %State{} = state) do - case State.register_engine_token(state, token, title, opts) do - {:ok, new_state} -> {:noreply, new_state} - {:error, :rejected} -> {:noreply, state} - end - end - - def handle_info({:engine_progress_report, token, updates}, %State{} = state) do - case State.report(state, token, updates) do - {:ok, _token, new_state} -> {:noreply, new_state} - {:noop, state} -> {:noreply, state} - end - end - - def handle_info({:engine_progress_complete, token_or_ref, opts}, %State{} = state) do - case State.complete(state, token_or_ref, opts) do - {:ok, new_state} -> {:noreply, new_state} - {:error, :unknown_token, state} -> {:noreply, state} - {:error, :unknown_ref} -> {:noreply, state} - end - end - - def name(%Project{} = project), do: :"#{Project.name(project)}::progress" - - def whereis(%Project{} = project), do: project |> name() |> Process.whereis() -end diff --git a/apps/expert/lib/expert/project/progress/state.ex b/apps/expert/lib/expert/project/progress/state.ex deleted file mode 100644 index 58dca0c5..00000000 --- a/apps/expert/lib/expert/project/progress/state.ex +++ /dev/null @@ -1,206 +0,0 @@ -defmodule Expert.Project.Progress.State do - alias Expert.Configuration - alias Expert.Protocol.Id - alias Forge.Project - alias GenLSP.Requests - alias GenLSP.Structures - - require Logger - - defstruct project: nil, active: MapSet.new(), refs: %{} - - defguardp is_token(token) when is_binary(token) or is_integer(token) - - def new(%Project{} = project), do: %__MODULE__{project: project} - - @doc """ - Registers a client-initiated progress token with an optional `:ref`, which can be - used as a stable identifier to report and end progress. - """ - def register(%__MODULE__{} = state, token, opts \\ []) do - opts = Keyword.validate!(opts, [:ref]) - - active = MapSet.put(state.active, token) - state = %{state | active: active} - - case Keyword.get(opts, :ref) do - nil -> - {:ok, state} - - ref when is_atom(ref) -> - refs = Map.put(state.refs, ref, token) - {:ok, %{state | refs: refs}} - end - end - - @doc """ - Begins server-initiated progress. - - Generates a token, requests the client create the progress indicator, - and sends the begin notification. - """ - def begin(%__MODULE__{} = state, title, opts) do - lsp = Expert.get_lsp() - token = System.unique_integer([:positive]) - - case request_work_done_progress(lsp, token) do - :ok -> - notify_begin(lsp, token, title, opts) - active = MapSet.put(state.active, token) - {:ok, token, %{state | active: active}} - - {:error, reason} -> - Logger.warning("Client rejected progress token: #{inspect(reason)}") - {:error, :rejected} - end - end - - @doc """ - Registers an engine-initiated progress token. - - The token is generated by the engine and passed here. Requests the client - create the progress indicator, sends the begin notification, and adds to active. - - If opts contains `:ref`, also registers a ref → token mapping so the engine - can use `report_to_ref` and `complete_ref` without tracking the token. - """ - def register_engine_token(%__MODULE__{} = state, token, title, opts) when is_token(token) do - lsp = Expert.get_lsp() - - case request_work_done_progress(lsp, token) do - :ok -> - notify_begin(lsp, token, title, opts) - active = MapSet.put(state.active, token) - state = %{state | active: active} - - # If opts contains :ref, register the ref → token mapping - state = - case Keyword.get(opts, :ref) do - nil -> state - ref when is_atom(ref) -> %{state | refs: Map.put(state.refs, ref, token)} - end - - {:ok, state} - - {:error, reason} -> - Logger.warning("Client rejected engine progress token: #{inspect(reason)}") - {:error, :rejected} - end - end - - @doc """ - Reports progress to the client. - - Returns `{:ok, token, state}` on success, `{:noop, state}` if token/ref not found. - """ - def report(%__MODULE__{} = state, token, updates) when is_token(token) do - if MapSet.member?(state.active, token) do - lsp = Expert.get_lsp() - notify_report(lsp, token, updates) - {:ok, token, state} - else - Logger.warning("Progress report for unknown token: #{inspect(token)}") - {:noop, state} - end - end - - def report(%__MODULE__{} = state, ref, updates) when is_atom(ref) do - case Map.fetch(state.refs, ref) do - {:ok, token} -> report(state, token, updates) - :error -> {:noop, state} - end - end - - @doc """ - Completes a progress sequence by ref atom or token. - """ - def complete(state, ref_or_token, opts) - - def complete(%__MODULE__{} = state, ref, opts) when is_atom(ref) do - case Map.pop(state.refs, ref) do - {token, refs} when is_token(token) -> complete(%{state | refs: refs}, token, opts) - {nil, _} -> {:error, :unknown_ref} - end - end - - def complete(%__MODULE__{} = state, token, opts) when is_token(token) do - if MapSet.member?(state.active, token) do - lsp = Expert.get_lsp() - notify_end(lsp, token, opts) - active = MapSet.delete(state.active, token) - {:ok, %{state | active: active}} - else - Logger.warning("Progress complete for unknown token: #{inspect(token)}") - {:error, :unknown_token, state} - end - end - - defp notify_begin(lsp, token, title, opts) do - if Configuration.client_supports?(:work_done_progress) do - :ok = - GenLSP.notify(lsp, %GenLSP.Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: token, - value: %Structures.WorkDoneProgressBegin{ - kind: "begin", - title: title, - message: Keyword.get(opts, :message), - percentage: Keyword.get(opts, :percentage), - cancellable: Keyword.get(opts, :cancellable) - } - } - }) - end - end - - defp notify_report(lsp, token, updates) do - if Configuration.client_supports?(:work_done_progress) do - :ok = - GenLSP.notify(lsp, %GenLSP.Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: token, - value: %Structures.WorkDoneProgressReport{ - kind: "report", - message: Keyword.get(updates, :message), - percentage: Keyword.get(updates, :percentage) - } - } - }) - end - end - - defp notify_end(lsp, token, opts) do - if Configuration.client_supports?(:work_done_progress) do - :ok = - GenLSP.notify(lsp, %GenLSP.Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: token, - value: %Structures.WorkDoneProgressEnd{ - kind: "end", - message: Keyword.get(opts, :message) - } - } - }) - end - end - - defp request_work_done_progress(lsp, token) do - if Configuration.client_supports?(:work_done_progress) do - result = - GenLSP.request( - lsp, - %Requests.WindowWorkDoneProgressCreate{ - id: Id.next(), - params: %Structures.WorkDoneProgressCreateParams{token: token} - } - ) - - case result do - nil -> :ok - error -> {:error, error} - end - else - :ok - end - end -end diff --git a/apps/expert/lib/expert/project/supervisor.ex b/apps/expert/lib/expert/project/supervisor.ex index d23d6d65..2abfb872 100644 --- a/apps/expert/lib/expert/project/supervisor.ex +++ b/apps/expert/lib/expert/project/supervisor.ex @@ -3,7 +3,6 @@ defmodule Expert.Project.Supervisor do alias Expert.Project.Diagnostics alias Expert.Project.Intelligence alias Expert.Project.Node - alias Expert.Project.Progress alias Expert.Project.SearchListener alias Forge.Project @@ -25,7 +24,6 @@ defmodule Expert.Project.Supervisor do def init(%Project{} = project) do children = [ - {Progress, project}, {EngineSupervisor, project}, {Node, project}, {Diagnostics, project}, diff --git a/apps/expert/test/expert/progress_test.exs b/apps/expert/test/expert/progress_test.exs new file mode 100644 index 00000000..01425f3d --- /dev/null +++ b/apps/expert/test/expert/progress_test.exs @@ -0,0 +1,223 @@ +defmodule Expert.ProgressTest do + use ExUnit.Case + use Patch + + alias Expert.Progress + alias GenLSP.Notifications + alias GenLSP.Requests + alias GenLSP.Structures + + setup do + test_pid = self() + lsp = spawn(fn -> Process.sleep(:infinity) end) + + patch(Expert, :get_lsp, fn -> lsp end) + patch(Expert.Configuration, :client_supports?, fn :work_done_progress -> true end) + + # Mock GenLSP.request to return nil (success) and send the request to test process + patch(GenLSP, :request, fn ^lsp, request -> + send(test_pid, {:request, request}) + nil + end) + + # Mock GenLSP.notify to send the notification to test process + patch(GenLSP, :notify, fn ^lsp, notification -> + send(test_pid, {:notify, notification}) + :ok + end) + + on_exit(fn -> Process.exit(lsp, :kill) end) + + :ok + end + + describe "begin/2" do + test "generates a token and sends begin notification" do + assert {:ok, token} = Progress.begin("Building") + + assert is_integer(token) + + # Should request the client to create the progress + assert_received {:request, %Requests.WindowWorkDoneProgressCreate{params: params}} + assert params.token == token + + # Should send begin notification + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.token == token + assert %Structures.WorkDoneProgressBegin{} = params.value + assert params.value.title == "Building" + assert params.value.kind == "begin" + end + + test "passes options to begin notification" do + {:ok, _token} = Progress.begin("Building", message: "Starting...", percentage: 0) + + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.value.message == "Starting..." + assert params.value.percentage == 0 + end + + test "returns error when client rejects the token" do + patch(GenLSP, :request, fn _lsp, _request -> {:error, :rejected} end) + + assert {:error, :rejected} = Progress.begin("Building") + end + end + + describe "report/2" do + test "sends report notification" do + {:ok, token} = Progress.begin("Building") + # Clear the received messages + assert_received {:request, _} + assert_received {:notify, _} + + :ok = Progress.report(token, message: "Processing...") + + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.token == token + assert %Structures.WorkDoneProgressReport{} = params.value + assert params.value.message == "Processing..." + assert params.value.kind == "report" + end + + test "supports percentage option" do + {:ok, token} = Progress.begin("Building") + assert_received {:request, _} + assert_received {:notify, _} + + :ok = Progress.report(token, message: "Halfway", percentage: 50) + + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.value.percentage == 50 + end + end + + describe "complete/2" do + test "sends end notification" do + {:ok, token} = Progress.begin("Building") + assert_received {:request, _} + assert_received {:notify, _} + + :ok = Progress.complete(token, message: "Done!") + + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.token == token + assert %Structures.WorkDoneProgressEnd{} = params.value + assert params.value.message == "Done!" + assert params.value.kind == "end" + end + end + + describe "with_progress/3" do + test "wraps work with begin/complete" do + result = Progress.with_progress("Building", fn _token -> {:done, :ok} end) + + assert result == :ok + + # Should have begin notification + assert_received {:request, _} + assert_received {:notify, %Notifications.DollarProgress{params: begin_params}} + assert begin_params.value.kind == "begin" + + # Should have end notification + assert_received {:notify, %Notifications.DollarProgress{params: end_params}} + assert end_params.value.kind == "end" + end + + test "passes final message on completion" do + Progress.with_progress("Building", fn _token -> {:done, :ok, "Build complete"} end) + + assert_received {:request, _} + assert_received {:notify, _} + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.value.message == "Build complete" + end + + test "handles cancel result" do + result = Progress.with_progress("Building", fn _token -> {:cancel, :cancelled} end) + + assert result == :cancelled + + assert_received {:request, _} + assert_received {:notify, _} + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.value.message == "Cancelled" + end + + test "handles exceptions" do + assert_raise RuntimeError, "oops", fn -> + Progress.with_progress("Building", fn _token -> raise "oops" end) + end + + assert_received {:request, _} + assert_received {:notify, _} + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.value.message == "Error: oops" + end + + test "allows reporting during work" do + Progress.with_progress("Building", fn token -> + Progress.report(token, message: "Step 1") + Progress.report(token, message: "Step 2") + {:done, :ok} + end) + + assert_received {:request, _} + assert_received {:notify, %Notifications.DollarProgress{params: begin_params}} + assert begin_params.value.kind == "begin" + + assert_received {:notify, %Notifications.DollarProgress{params: report1}} + assert report1.value.message == "Step 1" + + assert_received {:notify, %Notifications.DollarProgress{params: report2}} + assert report2.value.message == "Step 2" + + assert_received {:notify, %Notifications.DollarProgress{params: end_params}} + assert end_params.value.kind == "end" + end + end + + describe "with_client_progress/2" do + test "uses client-provided token" do + client_token = "client-token-123" + + result = + Progress.with_client_progress(client_token, fn token -> + assert token == client_token + {:done, :ok} + end) + + assert result == :ok + + # Should NOT request token creation (client already did) + refute_received {:request, _} + + # Should send end notification with client token + assert_received {:notify, %Notifications.DollarProgress{params: params}} + assert params.token == client_token + end + end + + describe "when client does not support progress" do + setup do + patch(Expert.Configuration, :client_supports?, fn :work_done_progress -> false end) + :ok + end + + test "begin still returns a token" do + assert {:ok, token} = Progress.begin("Building") + assert is_integer(token) + + # Should NOT send any requests or notifications + refute_received {:request, _} + refute_received {:notify, _} + end + + test "with_progress still executes the work" do + result = Progress.with_progress("Building", fn _token -> {:done, :ok} end) + + assert result == :ok + refute_received {:notify, _} + end + end +end diff --git a/apps/expert/test/expert/project/progress/state_test.exs b/apps/expert/test/expert/project/progress/state_test.exs deleted file mode 100644 index 90400ccc..00000000 --- a/apps/expert/test/expert/project/progress/state_test.exs +++ /dev/null @@ -1,156 +0,0 @@ -defmodule Expert.Project.Progress.StateTest do - alias Expert.Configuration - alias Expert.Project.Progress.State - - import Forge.Test.Fixtures - - use ExUnit.Case, async: true - use Patch - - setup do - project = project() - # Mock LSP interactions - # GenLSP.request returns nil for success, non-nil for error - patch(Expert, :get_lsp, fn -> self() end) - patch(GenLSP, :request, fn _, _ -> nil end) - patch(GenLSP, :notify, fn _, _ -> :ok end) - patch(Configuration, :client_supports?, fn :work_done_progress -> true end) - {:ok, project: project} - end - - describe "engine-initiated progress" do - test "register_engine_token adds token to active and sends begin notification", %{ - project: project - } do - state = State.new(project) - token = 12345 - title = "mix compile" - - {:ok, new_state} = State.register_engine_token(state, token, title, []) - - assert MapSet.member?(new_state.active, token) - end - - test "report works for registered engine token", %{project: project} do - state = State.new(project) - token = 12345 - - {:ok, state} = State.register_engine_token(state, token, "mix compile", []) - {:ok, ^token, _state} = State.report(state, token, message: "Compiling...") - - # Should not error - assert true - end - - test "report returns noop for unknown engine token", %{project: project} do - state = State.new(project) - - assert {:noop, _state} = State.report(state, 99999, message: "test") - end - - test "complete removes engine token from active", %{project: project} do - state = State.new(project) - token = 12345 - - {:ok, state} = State.register_engine_token(state, token, "mix compile", []) - {:ok, new_state} = State.complete(state, token, []) - - refute MapSet.member?(new_state.active, token) - end - - test "complete returns error for unknown engine token", %{project: project} do - state = State.new(project) - - assert {:error, :unknown_token, _state} = State.complete(state, 99999, []) - end - end - - describe "server-initiated progress" do - test "begin creates token and tracks in active set", %{project: project} do - state = State.new(project) - title = "Building" - - {:ok, token, new_state} = State.begin(state, title, []) - - assert is_integer(token) - assert MapSet.member?(new_state.active, token) - end - - test "report works for active token", %{project: project} do - state = State.new(project) - - {:ok, token, state} = State.begin(state, "Building", []) - {:ok, ^token, _state} = State.report(state, token, message: "In progress...") - - assert true - end - - test "report returns noop for unknown token", %{project: project} do - state = State.new(project) - - assert {:noop, _state} = State.report(state, 12345, message: "test") - end - - test "complete removes token from active set", %{project: project} do - state = State.new(project) - - {:ok, token, state} = State.begin(state, "Building", []) - {:ok, new_state} = State.complete(state, token, []) - - refute MapSet.member?(new_state.active, token) - end - end - - describe "ref-based progress" do - test "register tracks token with ref", %{project: project} do - state = State.new(project) - token = "client-token" - - {:ok, new_state} = State.register(state, token, ref: :initialize) - - assert MapSet.member?(new_state.active, token) - assert new_state.refs[:initialize] == token - end - - test "register without ref only tracks token", %{project: project} do - state = State.new(project) - token = "client-token" - - {:ok, new_state} = State.register(state, token, []) - - assert MapSet.member?(new_state.active, token) - assert new_state.refs == %{} - end - - test "report works for known ref", %{project: project} do - state = State.new(project) - token = "client-token" - - {:ok, state} = State.register(state, token, ref: :initialize) - {:ok, ^token, _state} = State.report(state, :initialize, message: "Loading...") - end - - test "report returns noop for unknown ref", %{project: project} do - state = State.new(project) - - assert {:noop, _state} = State.report(state, :unknown, message: "test") - end - - test "complete removes ref and token from tracking", %{project: project} do - state = State.new(project) - token = "client-token" - - {:ok, state} = State.register(state, token, ref: :initialize) - {:ok, new_state} = State.complete(state, :initialize, []) - - refute Map.has_key?(new_state.refs, :initialize) - refute MapSet.member?(new_state.active, token) - end - - test "complete returns error for unknown ref", %{project: project} do - state = State.new(project) - - assert {:error, :unknown_ref} = State.complete(state, :unknown, []) - end - end -end diff --git a/apps/expert/test/expert/project/progress_test.exs b/apps/expert/test/expert/project/progress_test.exs deleted file mode 100644 index cec85b00..00000000 --- a/apps/expert/test/expert/project/progress_test.exs +++ /dev/null @@ -1,297 +0,0 @@ -defmodule Expert.Project.ProgressTest do - alias Expert.Configuration - alias Expert.EngineApi - alias Expert.Project - alias Expert.Test.DispatchFake - alias GenLSP.Notifications - alias GenLSP.Requests - alias GenLSP.Structures - - import Forge.Test.Fixtures - - use ExUnit.Case - use Patch - use DispatchFake - use Forge.Test.EventualAssertions - - @progress_message_types [ - :engine_progress_begin, - :engine_progress_report, - :engine_progress_complete - ] - - setup do - project = project() - pid = start_supervised!({Project.Progress, project}) - DispatchFake.start() - - for type <- @progress_message_types do - Engine.Dispatch.register_listener(pid, type) - end - - {:ok, project: project} - end - - def with_patched_transport(_) do - test = self() - - patch(Expert, :get_lsp, fn -> self() end) - - patch(GenLSP, :notify, fn _, message -> - send(test, {:transport, message}) - :ok - end) - - # GenLSP.request returns nil for success - patch(GenLSP, :request, fn _, message -> - send(test, {:transport, message}) - nil - end) - - :ok - end - - def with_work_done_progress_support(_) do - patch(Configuration, :client_supports?, fn :work_done_progress -> true end) - :ok - end - - describe "engine-initiated progress" do - setup [:with_patched_transport, :with_work_done_progress_support] - - test "it should send begin/report/complete notifications", %{project: project} do - # Engine generates token and broadcasts begin - engine_token = 12345 - - EngineApi.broadcast(project, {:engine_progress_begin, engine_token, "mix compile", []}) - - assert_receive {:transport, - %Requests.WindowWorkDoneProgressCreate{ - params: %Structures.WorkDoneProgressCreateParams{token: ^engine_token} - }} - - assert_receive {:transport, %Notifications.DollarProgress{params: %{value: value}}} - assert value.kind == "begin" - assert value.title == "mix compile" - - # Report progress - EngineApi.broadcast( - project, - {:engine_progress_report, engine_token, [message: "lib/file.ex"]} - ) - - assert_receive {:transport, - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{token: ^engine_token, value: value} - }} - - assert value.kind == "report" - assert value.message == "lib/file.ex" - - # Complete progress - EngineApi.broadcast( - project, - {:engine_progress_complete, engine_token, [message: "Done"]} - ) - - assert_receive {:transport, - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{token: ^engine_token, value: value} - }} - - assert value.kind == "end" - assert value.message == "Done" - end - - test "it should support percentage updates", %{project: project} do - engine_token = 67890 - - EngineApi.broadcast( - project, - {:engine_progress_begin, engine_token, "indexing", [percentage: 0]} - ) - - assert_receive {:transport, - %Requests.WindowWorkDoneProgressCreate{params: %{token: ^engine_token}}} - - assert_receive {:transport, %Notifications.DollarProgress{params: %{value: value}}} - - assert value.kind == "begin" - assert value.title == "indexing" - assert value.percentage == 0 - - EngineApi.broadcast( - project, - {:engine_progress_report, engine_token, [message: "Processing...", percentage: 50]} - ) - - assert_receive {:transport, - %Notifications.DollarProgress{ - params: %Structures.ProgressParams{token: ^engine_token, value: value} - }} - - assert value.kind == "report" - assert value.percentage == 50 - assert value.message == "Processing..." - - EngineApi.broadcast( - project, - {:engine_progress_complete, engine_token, [message: "Complete"]} - ) - - assert_receive {:transport, - %Notifications.DollarProgress{params: %{token: ^engine_token, value: value}}} - - assert value.kind == "end" - assert value.message == "Complete" - end - - test "it should write nothing when the client does not support work done", %{project: project} do - patch(Configuration, :client_supports?, fn :work_done_progress -> false end) - - EngineApi.broadcast(project, {:engine_progress_begin, 11111, "mix compile", []}) - - refute_receive {:transport, %Requests.WindowWorkDoneProgressCreate{params: %{}}} - end - - test "it ignores updates for unknown tokens", %{project: project} do - # Report/complete without a matching begin should not crash - EngineApi.broadcast(project, {:engine_progress_report, 99999, [message: "test"]}) - EngineApi.broadcast(project, {:engine_progress_complete, 99999, []}) - - # Should not receive any notifications for unknown progress - refute_receive {:transport, %Notifications.DollarProgress{}} - end - end - - describe "manual progress API" do - setup [:with_patched_transport, :with_work_done_progress_support] - - test "register/3 registers a client-initiated progress with ref", %{project: project} do - client_token = "client-token-123" - - :ok = Project.Progress.register(project, client_token, ref: :initialize) - - # Report using the ref (cast, returns :ok immediately) - :ok = Project.Progress.report(project, :initialize, message: "Loading...") - - assert_receive {:transport, - %Notifications.DollarProgress{ - params: %{token: ^client_token, value: value} - }} - - assert value.kind == "report" - assert value.message == "Loading..." - - # End using the ref - :ok = Project.Progress.complete(project, :initialize, message: "Done") - - assert_receive {:transport, - %Notifications.DollarProgress{ - params: %{token: ^client_token, value: value} - }} - - assert value.kind == "end" - assert value.message == "Done" - end - - test "begin/3 starts server-initiated progress and returns token", %{project: project} do - {:ok, token} = Project.Progress.begin(project, "Building", message: "Starting...") - - assert is_integer(token) - - assert_receive {:transport, - %Requests.WindowWorkDoneProgressCreate{params: %{token: ^token}}} - - assert_receive {:transport, - %Notifications.DollarProgress{params: %{token: ^token, value: value}}} - - assert value.kind == "begin" - assert value.title == "Building" - assert value.message == "Starting..." - end - - test "report/3 with token sends progress update", %{project: project} do - {:ok, token} = Project.Progress.begin(project, "Processing") - - # Drain begin notifications - assert_receive {:transport, %Requests.WindowWorkDoneProgressCreate{}} - - assert_receive {:transport, - %Notifications.DollarProgress{params: %{value: %{kind: "begin"}}}} - - # report is now a cast, returns :ok immediately - :ok = Project.Progress.report(project, token, message: "50% complete", percentage: 50) - - assert_receive {:transport, - %Notifications.DollarProgress{params: %{token: ^token, value: value}}} - - assert value.kind == "report" - assert value.message == "50% complete" - assert value.percentage == 50 - end - - test "report/3 with unknown ref is a no-op", %{project: project} do - # report is a cast, always returns :ok (unknown refs are silently ignored with warning log) - result = Project.Progress.report(project, :nonexistent_ref, message: "test") - assert result == :ok - end - - test "complete/3 with token completes progress", %{project: project} do - {:ok, token} = Project.Progress.begin(project, "Task") - - # Drain begin notifications - assert_receive {:transport, %Requests.WindowWorkDoneProgressCreate{}} - - assert_receive {:transport, - %Notifications.DollarProgress{params: %{value: %{kind: "begin"}}}} - - :ok = Project.Progress.complete(project, token, message: "Complete!") - - assert_receive {:transport, - %Notifications.DollarProgress{params: %{token: ^token, value: value}}} - - assert value.kind == "end" - assert value.message == "Complete!" - end - - test "complete/3 with unknown ref returns :ok (no-op)", %{project: project} do - result = Project.Progress.complete(project, :nonexistent_ref, message: "test") - assert result == :ok - end - - test "full manual workflow with server-initiated progress", %{project: project} do - # Begin - {:ok, token} = Project.Progress.begin(project, "Indexing", percentage: 0) - - assert_receive {:transport, - %Requests.WindowWorkDoneProgressCreate{params: %{token: ^token}}} - - assert_receive {:transport, - %Notifications.DollarProgress{params: %{token: ^token, value: value}}} - - assert value.kind == "begin" - assert value.percentage == 0 - - # Report multiple updates (cast, returns :ok) - for i <- [25, 50, 75] do - :ok = Project.Progress.report(project, token, message: "#{i}%", percentage: i) - - assert_receive {:transport, - %Notifications.DollarProgress{params: %{token: ^token, value: value}}} - - assert value.kind == "report" - assert value.percentage == i - end - - # End - :ok = Project.Progress.complete(project, token, message: "Indexed!") - - assert_receive {:transport, - %Notifications.DollarProgress{params: %{token: ^token, value: value}}} - - assert value.kind == "end" - assert value.message == "Indexed!" - end - end -end From aefe602fb2d2704a76c7fd3fa8e7e78a988199e9 Mon Sep 17 00:00:00 2001 From: Moosieus Date: Sat, 6 Dec 2025 17:50:43 -0500 Subject: [PATCH 03/17] allow custom tokens --- apps/engine/lib/engine/build.ex | 5 +++++ apps/engine/lib/engine/build/project.ex | 27 +++++++++++++------------ apps/engine/lib/engine/build/state.ex | 4 ++-- apps/engine/lib/engine/progress.ex | 6 ++++-- apps/expert/lib/expert/progress.ex | 7 +++++-- apps/expert/test/engine/build_test.exs | 5 ----- 6 files changed, 30 insertions(+), 24 deletions(-) diff --git a/apps/engine/lib/engine/build.ex b/apps/engine/lib/engine/build.ex index b9819a5a..822dc34d 100644 --- a/apps/engine/lib/engine/build.ex +++ b/apps/engine/lib/engine/build.ex @@ -12,6 +12,11 @@ defmodule Engine.Build do # Public interface + @doc """ + Token for reporting build progress to the language client. + """ + def progress_token(%Project{} = project), do: "build_engine:#{project.root_uri}" + def schedule_compile(%Project{} = _project, force? \\ false) do GenServer.cast(__MODULE__, {:compile, force?}) end diff --git a/apps/engine/lib/engine/build/project.ex b/apps/engine/lib/engine/build/project.ex index 80592ed2..a961a562 100644 --- a/apps/engine/lib/engine/build/project.ex +++ b/apps/engine/lib/engine/build/project.ex @@ -10,12 +10,11 @@ defmodule Engine.Build.Project do Engine.Mix.in_project(fn _ -> Mix.Task.clear() - prepare_for_project_build(initial?) + prepare_for_project_build(project, initial?) compile_fun = fn -> Mix.Task.clear() - - Progress.report(:initialize, message: building_label(project)) + do_progress_report(project, message: building_label(project)) result = compile_in_isolation() Mix.Task.run(:loadpaths) result @@ -62,36 +61,38 @@ defmodule Engine.Build.Project do end end - defp prepare_for_project_build(false = _initial?) do - :ok - end + defp prepare_for_project_build(_project, false = _initial?), do: :ok - defp prepare_for_project_build(true = _initial?) do + defp prepare_for_project_build(project, true = _initial?) do if connected_to_internet?() do - Progress.report(:initialize, message: "mix local.hex") + do_progress_report(project, message: "mix local.hex") Mix.Task.run("local.hex", ~w(--force)) - Progress.report(:initialize, message: "mix local.rebar") + do_progress_report(project, message: "mix local.rebar") Mix.Task.run("local.rebar", ~w(--force)) - Progress.report(:initialize, message: "mix deps.get") + do_progress_report(project, message: "mix deps.get") Mix.Task.run("deps.get") else Logger.warning("Could not connect to hex.pm, dependencies will not be fetched") end - Progress.report(:initialize, message: "mix loadconfig") + do_progress_report(project, message: "mix loadconfig") Mix.Task.run(:loadconfig) if not Elixir.Features.compile_keeps_current_directory?() do - Progress.report(:initialize, message: "mix deps.compile") + do_progress_report(project, message: "mix deps.compile") Mix.Task.run("deps.safe_compile", ~w(--skip-umbrella-children)) end - Progress.report(:initialize, message: "Loading plugins") + do_progress_report(project, message: "Loading plugins") Plugin.Discovery.run() end + defp do_progress_report(project, opts) do + Progress.report(Build.progress_token(project), opts) + end + defp connected_to_internet? do # While there's no perfect way to check if a computer is connected to the internet, # it seems reasonable to gate pulling dependencies on a resolution check for hex.pm. diff --git a/apps/engine/lib/engine/build/state.ex b/apps/engine/lib/engine/build/state.ex index 0d2e395e..011e8197 100644 --- a/apps/engine/lib/engine/build/state.ex +++ b/apps/engine/lib/engine/build/state.ex @@ -83,7 +83,7 @@ defmodule Engine.Build.State do project = state.project Build.with_lock(fn -> - work_done_token = Progress.begin(building_label(project)) + token = Progress.begin(building_label(project), token: Build.progress_token(project)) try do compile_requested_message = @@ -125,7 +125,7 @@ defmodule Engine.Build.State do Plugin.diagnose(project, state.build_number) after Tracer.clear_build_token() - Progress.complete(work_done_token) + Progress.complete(token) end end) diff --git a/apps/engine/lib/engine/progress.ex b/apps/engine/lib/engine/progress.ex index 42a9b3c8..56905d8f 100644 --- a/apps/engine/lib/engine/progress.ex +++ b/apps/engine/lib/engine/progress.ex @@ -86,8 +86,10 @@ defmodule Engine.Progress do - `:message` - Status message to display - `:percentage` - Progress percentage 0-100 """ - @spec report(integer(), keyword()) :: :ok - def report(token, updates \\ []) when is_integer(token) do + @spec report(integer() | String.t(), keyword()) :: :ok + def report(token, updates \\ []) + + def report(token, updates) when is_integer(token) or is_binary(token) do Dispatch.rpc_cast(Expert.Progress, :report, [token, updates]) :ok end diff --git a/apps/expert/lib/expert/progress.ex b/apps/expert/lib/expert/progress.ex index ecbf19b3..611a6292 100644 --- a/apps/expert/lib/expert/progress.ex +++ b/apps/expert/lib/expert/progress.ex @@ -53,16 +53,19 @@ defmodule Expert.Progress do * `:message` - Initial status message (optional) * `:percentage` - Initial percentage 0-100 (optional) * `:cancellable` - Whether the client can cancel (default: false) + * `:token` - Custom token to use (caller ensures uniqueness) ## Examples {:ok, token} = Progress.begin("Building project") {:ok, token} = Progress.begin("Indexing", message: "Starting...", percentage: 0) + {:ok, token} = Progress.begin("Custom", token: my_unique_token) """ @spec begin(String.t(), keyword()) :: {:ok, integer()} | {:error, :rejected} def begin(title, opts \\ []) do - opts = Keyword.validate!(opts, [:message, :percentage, :cancellable]) - token = System.unique_integer([:positive]) + opts = Keyword.validate!(opts, [:message, :percentage, :cancellable, :token]) + + token = opts[:token] || System.unique_integer([:positive]) if Configuration.client_supports?(:work_done_progress) do case request_work_done_progress(token) do diff --git a/apps/expert/test/engine/build_test.exs b/apps/expert/test/engine/build_test.exs index 961ff526..35180c64 100644 --- a/apps/expert/test/engine/build_test.exs +++ b/apps/expert/test/engine/build_test.exs @@ -77,8 +77,6 @@ defmodule Engine.BuildTest do EngineApi.schedule_compile(project, true) assert_receive project_compiled(status: :success) - assert_receive {:engine_progress_begin, _token, "Building " <> project_name, _} - assert project_name == "project_metadata" end test "receives metadata about the defined modules" do @@ -113,9 +111,6 @@ defmodule Engine.BuildTest do assert {:arity_0, 0} in functions assert {:arity_1, 1} in functions assert {:arity_2, 2} in functions - - assert_receive {:engine_progress_begin, _token, "Building " <> project_name, _} - assert project_name == "umbrella" end end From e0925dac2e5138a71d5b900f64e46cd69c128b0d Mon Sep 17 00:00:00 2001 From: Moosieus Date: Mon, 8 Dec 2025 00:23:43 -0500 Subject: [PATCH 04/17] Fix outstanding progress bugs --- apps/engine/lib/engine/build/state.ex | 3 +- apps/engine/lib/engine/compilation/tracer.ex | 12 ++------ apps/engine/lib/engine/dispatch.ex | 8 ++--- apps/engine/lib/engine/progress.ex | 18 +++++------ apps/engine/lib/engine/search/indexer.ex | 17 ++++------- apps/expert/lib/expert/engine_node.ex | 32 +++++++++----------- apps/expert/lib/expert/project/node.ex | 2 +- apps/expert/lib/expert/state.ex | 12 ++++++-- 8 files changed, 48 insertions(+), 56 deletions(-) diff --git a/apps/engine/lib/engine/build/state.ex b/apps/engine/lib/engine/build/state.ex index 011e8197..d825d086 100644 --- a/apps/engine/lib/engine/build/state.ex +++ b/apps/engine/lib/engine/build/state.ex @@ -83,7 +83,7 @@ defmodule Engine.Build.State do project = state.project Build.with_lock(fn -> - token = Progress.begin(building_label(project), token: Build.progress_token(project)) + {:ok, token} = Progress.begin(building_label(project), token: Build.progress_token(project)) try do compile_requested_message = @@ -124,7 +124,6 @@ defmodule Engine.Build.State do Engine.broadcast(diagnostics_message) Plugin.diagnose(project, state.build_number) after - Tracer.clear_build_token() Progress.complete(token) end end) diff --git a/apps/engine/lib/engine/compilation/tracer.ex b/apps/engine/lib/engine/compilation/tracer.ex index 817ef778..0d5071be 100644 --- a/apps/engine/lib/engine/compilation/tracer.ex +++ b/apps/engine/lib/engine/compilation/tracer.ex @@ -1,6 +1,7 @@ defmodule Engine.Compilation.Tracer do alias Engine.Module.Loader alias Engine.Progress + alias Engine.Build import Forge.EngineApi.Messages @@ -57,10 +58,7 @@ defmodule Engine.Compilation.Tracer do defp maybe_report_progress(file) do if Path.extname(file) == ".ex" do - case get_build_token() do - nil -> :noop - token -> Progress.report(token, message: progress_message(file)) - end + Progress.report(Build.progress_token(), message: progress_message(file)) end end @@ -75,10 +73,4 @@ defmodule Engine.Compilation.Tracer do "compiling: " <> Path.join([base_dir, "...", file_name]) end - - # Kludgy persistent_term for build token access - @build_token_key {__MODULE__, :build_token} - def set_build_token(token), do: :persistent_term.put(@build_token_key, token) - def clear_build_token, do: :persistent_term.erase(@build_token_key) - defp get_build_token, do: :persistent_term.get(@build_token_key, nil) end diff --git a/apps/engine/lib/engine/dispatch.ex b/apps/engine/lib/engine/dispatch.ex index 5ef55432..01c01fdc 100644 --- a/apps/engine/lib/engine/dispatch.ex +++ b/apps/engine/lib/engine/dispatch.ex @@ -70,18 +70,18 @@ defmodule Engine.Dispatch do @doc """ :rpc.call to the server node. """ - def rpc_call(module, function, args) do + def erpc_call(module, function, args) do project = Engine.get_project() manager_node = Project.manager_node_name(project) - :rpc.call(manager_node, module, function, args) + :erpc.call(manager_node, module, function, args, 1_000) end @doc """ :rpc.cast to the server node. """ - def rpc_cast(module, function, args) do + def erpc_cast(module, function, args) do project = Engine.get_project() manager_node = Project.manager_node_name(project) - :rpc.cast(manager_node, module, function, args) + :erpc.cast(manager_node, module, function, args) end end diff --git a/apps/engine/lib/engine/progress.ex b/apps/engine/lib/engine/progress.ex index 56905d8f..aa115527 100644 --- a/apps/engine/lib/engine/progress.ex +++ b/apps/engine/lib/engine/progress.ex @@ -5,9 +5,13 @@ defmodule Engine.Progress do alias Engine.Dispatch + require Logger + @type work_result :: {:done, term()} | {:done, term(), String.t()} | {:cancel, term()} @type work_fn :: (integer() -> work_result()) + defguardp is_token(token) when is_binary(token) or is_integer(token) + @doc """ Wraps work with progress reporting. @@ -71,11 +75,7 @@ defmodule Engine.Progress do """ @spec begin(String.t(), keyword()) :: integer() def begin(title, opts \\ []) do - # TODO: BAD. - case Dispatch.rpc_call(Expert.Progress, :begin, [title, opts]) do - {:ok, token} -> token - _ -> -1 - end + Dispatch.erpc_call(Expert.Progress, :begin, [title, opts]) end @doc """ @@ -89,8 +89,8 @@ defmodule Engine.Progress do @spec report(integer() | String.t(), keyword()) :: :ok def report(token, updates \\ []) - def report(token, updates) when is_integer(token) or is_binary(token) do - Dispatch.rpc_cast(Expert.Progress, :report, [token, updates]) + def report(token, updates) when is_token(token) do + Dispatch.erpc_cast(Expert.Progress, :report, [token, updates]) :ok end @@ -102,8 +102,8 @@ defmodule Engine.Progress do - `:message` - Final completion message """ @spec complete(integer(), keyword()) :: :ok - def complete(token, opts \\ []) when is_integer(token) do - Dispatch.rpc_cast(Expert.Progress, :complete, [token, opts]) + def complete(token, opts \\ []) when is_token(token) do + Dispatch.erpc_cast(Expert.Progress, :complete, [token, opts]) :ok end end diff --git a/apps/engine/lib/engine/search/indexer.ex b/apps/engine/lib/engine/search/indexer.ex index 69dc26b1..75e6a8b1 100644 --- a/apps/engine/lib/engine/search/indexer.ex +++ b/apps/engine/lib/engine/search/indexer.ex @@ -102,9 +102,7 @@ defmodule Engine.Search.Indexer do total_bytes = paths_to_sizes |> Enum.map(&elem(&1, 1)) |> Enum.sum() if total_bytes > 0 do - # Start progress tracking - token = Progress.begin("Indexing source code", percentage: 0) - bytes_processed = :counters.new(1, [:atomics]) + {:ok, token} = Progress.begin("Indexing source code", percentage: 0) initial_state = {0, []} @@ -120,13 +118,12 @@ defmodule Engine.Search.Indexer do end after_fn = fn - {_, []} -> - {:cont, []} - - {_, paths} -> - {:cont, paths, []} + {_, []} -> {:cont, []} + {_, paths} -> {:cont, paths, []} end + bytes_processed = :counters.new(1, [:atomics]) + paths_to_sizes |> Stream.chunk_while(initial_state, chunk_fn, after_fn) |> Task.async_stream( @@ -157,9 +154,7 @@ defmodule Engine.Search.Indexer do # will flatten the resulting steam {chunk_items, acc} end, - fn _acc -> - Progress.complete(token) - end + fn _acc -> Progress.complete(token) end ) else [] diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index fc454f54..5c7f5a73 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -218,25 +218,23 @@ defmodule Expert.EngineNode do launcher = Expert.Port.path() + Logger.info("Finding or building engine for project #{project_name}") GenLSP.info(lsp, "Finding or building engine for project #{project_name}") - Expert.Progress.with_progress( - "Building engine for #{project_name}", - fn _token -> - result = - fn -> - Process.flag(:trap_exit, true) - - {:spawn_executable, launcher} - |> Port.open(opts) - |> wait_for_engine() - end - |> Task.async() - |> Task.await(:infinity) - - {:done, result, "Engine node built for #{project_name}."} - end - ) + Expert.Progress.with_progress("Building engine for #{project_name}", fn _token -> + result = + fn -> + Process.flag(:trap_exit, true) + + {:spawn_executable, launcher} + |> Port.open(opts) + |> wait_for_engine() + end + |> Task.async() + |> Task.await(:infinity) + + {:done, result, "Engine node built for #{project_name}."} + end) {:error, :no_elixir, message} -> GenLSP.error(Expert.get_lsp(), message) diff --git a/apps/expert/lib/expert/project/node.ex b/apps/expert/lib/expert/project/node.ex index 092e083f..e5d4c7ef 100644 --- a/apps/expert/lib/expert/project/node.ex +++ b/apps/expert/lib/expert/project/node.ex @@ -50,7 +50,7 @@ defmodule Expert.Project.Node do @impl GenServer def init(%Project{} = project) do - Progress.with_progress("Project Node", fn _token -> + Progress.with_progress("Starting project node", fn _token -> result = start_node(project) {:done, result, "Project node started"} diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index ed894d79..c41fdc10 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -55,8 +55,16 @@ defmodule Expert.State do response = initialize_result() - {:ok, _pid} = Project.Supervisor.start(config.project) - send(Expert, :engine_initialized) + Task.Supervisor.start_child(:expert_task_queue, fn -> + # Race Cond: + # Project startup ends up calling `window/workDoneProgress/create` before + # the initialize response is returned. This is against spec and will result + # in the progress tokens being discarded. Adding a sleep here as a slim fix + # pending a more comprehensive solution. + Process.sleep(50) + {:ok, _pid} = Project.Supervisor.start(config.project) + send(Expert, :engine_initialized) + end) {:ok, response, new_state} end From b8b0ab3a664533164101f8ddd1959f601d54791e Mon Sep 17 00:00:00 2001 From: Moosieus Date: Sat, 13 Dec 2025 12:42:28 -0500 Subject: [PATCH 05/17] Move progress helper functions to a behavior --- apps/engine/lib/engine/build.ex | 17 +- apps/engine/lib/engine/build/project.ex | 28 +-- apps/engine/lib/engine/build/state.ex | 14 +- apps/engine/lib/engine/compilation/tracer.ex | 5 +- apps/engine/lib/engine/progress.ex | 99 +-------- apps/engine/lib/engine/search/indexer.ex | 87 ++++---- apps/engine/test/engine/build/state_test.exs | 9 +- .../engine/dispatch/handlers/indexer_test.exs | 6 + apps/engine/test/engine/progress_test.exs | 144 ++++++++++++- .../test/engine/search/indexer_test.exs | 6 + apps/expert/lib/expert/progress.ex | 117 +--------- .../completion/builder_test.exs | 45 ++++ apps/expert/test/expert/progress_test.exs | 21 -- apps/forge/lib/forge/progress.ex | 201 ++++++++++++++++++ apps/forge/lib/forge/progress/tracker.ex | 67 ++++++ 15 files changed, 557 insertions(+), 309 deletions(-) create mode 100644 apps/forge/lib/forge/progress.ex create mode 100644 apps/forge/lib/forge/progress/tracker.ex diff --git a/apps/engine/lib/engine/build.ex b/apps/engine/lib/engine/build.ex index 822dc34d..19c3f6ab 100644 --- a/apps/engine/lib/engine/build.ex +++ b/apps/engine/lib/engine/build.ex @@ -12,11 +12,6 @@ defmodule Engine.Build do # Public interface - @doc """ - Token for reporting build progress to the language client. - """ - def progress_token(%Project{} = project), do: "build_engine:#{project.root_uri}" - def schedule_compile(%Project{} = _project, force? \\ false) do GenServer.cast(__MODULE__, {:compile, force?}) end @@ -40,10 +35,18 @@ defmodule Engine.Build do :ok end - def with_lock(func) do - Engine.with_lock(__MODULE__, func) + def with_lock(func), do: Engine.with_lock(__MODULE__, func) + + # can't pass work token to Tracer module, so store it in persistent term. + + def set_progress_token(%Project{} = project) do + :persistent_term.put({__MODULE__, :progress_token}, "build_engine:#{project.root_uri}") end + def get_progress_token, do: :persistent_term.get({__MODULE__, :progress_token}) + + def clear_progress_token, do: :persistent_term.erase({__MODULE__, :progress_token}) + # GenServer Callbacks def start_link(_) do diff --git a/apps/engine/lib/engine/build/project.ex b/apps/engine/lib/engine/build/project.ex index a961a562..9153e1d1 100644 --- a/apps/engine/lib/engine/build/project.ex +++ b/apps/engine/lib/engine/build/project.ex @@ -10,11 +10,11 @@ defmodule Engine.Build.Project do Engine.Mix.in_project(fn _ -> Mix.Task.clear() - prepare_for_project_build(project, initial?) + if initial?, do: prepare_for_project_build() compile_fun = fn -> Mix.Task.clear() - do_progress_report(project, message: building_label(project)) + do_progress_report(message: "Building #{Project.display_name(project)}") result = compile_in_isolation() Mix.Task.run(:loadpaths) result @@ -61,37 +61,33 @@ defmodule Engine.Build.Project do end end - defp prepare_for_project_build(_project, false = _initial?), do: :ok - - defp prepare_for_project_build(project, true = _initial?) do + defp prepare_for_project_build() do if connected_to_internet?() do - do_progress_report(project, message: "mix local.hex") + do_progress_report(message: "mix local.hex") Mix.Task.run("local.hex", ~w(--force)) - do_progress_report(project, message: "mix local.rebar") + do_progress_report(message: "mix local.rebar") Mix.Task.run("local.rebar", ~w(--force)) - do_progress_report(project, message: "mix deps.get") + do_progress_report(message: "mix deps.get") Mix.Task.run("deps.get") else Logger.warning("Could not connect to hex.pm, dependencies will not be fetched") end - do_progress_report(project, message: "mix loadconfig") + do_progress_report(message: "mix loadconfig") Mix.Task.run(:loadconfig) if not Elixir.Features.compile_keeps_current_directory?() do - do_progress_report(project, message: "mix deps.compile") + do_progress_report(message: "mix deps.compile") Mix.Task.run("deps.safe_compile", ~w(--skip-umbrella-children)) end - do_progress_report(project, message: "Loading plugins") + do_progress_report(message: "Loading plugins") Plugin.Discovery.run() end - defp do_progress_report(project, opts) do - Progress.report(Build.progress_token(project), opts) - end + defp do_progress_report(opts), do: Progress.report(Build.get_progress_token(), opts) defp connected_to_internet? do # While there's no perfect way to check if a computer is connected to the internet, @@ -104,10 +100,6 @@ defmodule Engine.Build.Project do end end - def building_label(%Project{} = project) do - "Building #{Project.display_name(project)}" - end - defp mix_compile_opts do ~w( --return-errors diff --git a/apps/engine/lib/engine/build/state.ex b/apps/engine/lib/engine/build/state.ex index d825d086..aff1b3e3 100644 --- a/apps/engine/lib/engine/build/state.ex +++ b/apps/engine/lib/engine/build/state.ex @@ -1,7 +1,6 @@ defmodule Engine.Build.State do alias Elixir.Features alias Engine.Build - alias Engine.Compilation.Tracer alias Engine.Plugin alias Engine.Progress alias Forge.Document @@ -83,7 +82,13 @@ defmodule Engine.Build.State do project = state.project Build.with_lock(fn -> - {:ok, token} = Progress.begin(building_label(project), token: Build.progress_token(project)) + Build.set_progress_token(project) + + {:ok, token} = + Progress.begin( + "Building #{Project.display_name(project)}", + token: Build.get_progress_token() + ) try do compile_requested_message = @@ -124,6 +129,7 @@ defmodule Engine.Build.State do Engine.broadcast(diagnostics_message) Plugin.diagnose(project, state.build_number) after + Build.clear_progress_token() Progress.complete(token) end end) @@ -215,10 +221,6 @@ defmodule Engine.Build.State do end end - def building_label(%Project{} = project) do - "Building #{Project.display_name(project)}" - end - defp to_ms(microseconds) do microseconds / 1000 end diff --git a/apps/engine/lib/engine/compilation/tracer.ex b/apps/engine/lib/engine/compilation/tracer.ex index 0d5071be..c2a18fe2 100644 --- a/apps/engine/lib/engine/compilation/tracer.ex +++ b/apps/engine/lib/engine/compilation/tracer.ex @@ -57,8 +57,9 @@ defmodule Engine.Compilation.Tracer do end defp maybe_report_progress(file) do - if Path.extname(file) == ".ex" do - Progress.report(Build.progress_token(), message: progress_message(file)) + with ".ex" <- Path.extname(file), + token when not is_nil(token) <- Build.get_progress_token() do + Progress.report(token, message: progress_message(file)) end end diff --git a/apps/engine/lib/engine/progress.ex b/apps/engine/lib/engine/progress.ex index aa115527..348db5b5 100644 --- a/apps/engine/lib/engine/progress.ex +++ b/apps/engine/lib/engine/progress.ex @@ -3,107 +3,24 @@ defmodule Engine.Progress do LSP progress reporting for engine operations. """ - alias Engine.Dispatch - - require Logger - - @type work_result :: {:done, term()} | {:done, term(), String.t()} | {:cancel, term()} - @type work_fn :: (integer() -> work_result()) - - defguardp is_token(token) when is_binary(token) or is_integer(token) - - @doc """ - Wraps work with progress reporting. - - The `work_fn` receives the progress token and can call `Progress.report/2` directly: - - with_progress("Indexing", fn token -> - Progress.report(token, message: "Processing...") - do_work() - {:done, :ok} - end) - - The `work_fn` must return one of: - - `{:done, result}` - Operation completed successfully - - `{:done, result, message}` - Completed with a final message - - `{:cancel, result}` - Operation was cancelled - - ## Options - - - `:message` - Initial status message (optional) - - `:percentage` - Initial percentage 0-100 (optional) - - `:cancellable` - Whether the client can cancel (default: false) - """ - @spec with_progress(String.t(), work_fn(), keyword()) :: term() - def with_progress(title, work_fn, opts \\ []) when is_function(work_fn, 1) do - opts = Keyword.validate!(opts, [:message, :percentage, :cancellable]) - - token = begin(title, opts) - - try do - case work_fn.(token) do - {:done, result} -> - complete(token) - result + use Forge.Progress - {:done, result, message} -> - complete(token, message: message) - result - - {:cancel, result} -> - complete(token, message: "Cancelled") - result - end - rescue - e -> - complete(token, message: "Error: #{Exception.message(e)}") - reraise e, __STACKTRACE__ - end - end - - @doc """ - Manually begins a progress sequence with the given title. - - Generates a token internally and returns it for use with subsequent - `report/2` and `complete/2` calls. - - ## Options + alias Engine.Dispatch - - `:message` - Initial status message - - `:percentage` - Initial percentage 0-100 - - `:cancellable` - Whether the client can cancel - """ - @spec begin(String.t(), keyword()) :: integer() + @impl true def begin(title, opts \\ []) do Dispatch.erpc_call(Expert.Progress, :begin, [title, opts]) end - @doc """ - Reports progress for an in-progress operation. + @impl true + def report(token, opts \\ []) - ## Options - - - `:message` - Status message to display - - `:percentage` - Progress percentage 0-100 - """ - @spec report(integer() | String.t(), keyword()) :: :ok - def report(token, updates \\ []) - - def report(token, updates) when is_token(token) do - Dispatch.erpc_cast(Expert.Progress, :report, [token, updates]) - :ok + def report(token, opts) when is_token(token) do + Dispatch.erpc_cast(Expert.Progress, :report, [token, opts]) end - @doc """ - Completes a progress sequence. - - ## Options - - - `:message` - Final completion message - """ - @spec complete(integer(), keyword()) :: :ok + @impl true def complete(token, opts \\ []) when is_token(token) do Dispatch.erpc_cast(Expert.Progress, :complete, [token, opts]) - :ok end end diff --git a/apps/engine/lib/engine/search/indexer.ex b/apps/engine/lib/engine/search/indexer.ex index 75e6a8b1..957fbea7 100644 --- a/apps/engine/lib/engine/search/indexer.ex +++ b/apps/engine/lib/engine/search/indexer.ex @@ -102,60 +102,47 @@ defmodule Engine.Search.Indexer do total_bytes = paths_to_sizes |> Enum.map(&elem(&1, 1)) |> Enum.sum() if total_bytes > 0 do - {:ok, token} = Progress.begin("Indexing source code", percentage: 0) - - initial_state = {0, []} - - chunk_fn = fn {path, file_size}, {block_size, paths} -> - new_block_size = file_size + block_size - new_paths = [path | paths] - - if new_block_size >= @bytes_per_block do - {:cont, new_paths, initial_state} - else - {:cont, {new_block_size, new_paths}} + Progress.with_tracked_progress("Indexing source code", total_bytes, fn report -> + initial_state = {0, []} + + chunk_fn = fn {path, file_size}, {block_size, paths} -> + new_block_size = file_size + block_size + new_paths = [path | paths] + + if new_block_size >= @bytes_per_block do + {:cont, new_paths, initial_state} + else + {:cont, {new_block_size, new_paths}} + end end - end - after_fn = fn - {_, []} -> {:cont, []} - {_, paths} -> {:cont, paths, []} - end - - bytes_processed = :counters.new(1, [:atomics]) - - paths_to_sizes - |> Stream.chunk_while(initial_state, chunk_fn, after_fn) - |> Task.async_stream( - fn chunk -> - block_bytes = chunk |> Enum.map(&Map.get(path_to_size_map, &1)) |> Enum.sum() - result = Enum.map(chunk, processor) - - :counters.add(bytes_processed, 1, block_bytes) - processed = :counters.get(bytes_processed, 1) - percentage = min(100, div(processed * 100, total_bytes)) - - Progress.report(token, message: "Indexing", percentage: percentage) + after_fn = fn + {_, []} -> {:cont, []} + {_, paths} -> {:cont, paths, []} + end - result - end, - timeout: timeout - ) - |> Stream.flat_map(fn - {:ok, entry_chunks} -> entry_chunks - _ -> [] + result = + paths_to_sizes + |> Stream.chunk_while(initial_state, chunk_fn, after_fn) + |> Task.async_stream( + fn chunk -> + block_bytes = chunk |> Enum.map(&Map.get(path_to_size_map, &1)) |> Enum.sum() + result = Enum.flat_map(chunk, processor) + + report.(message: "Indexing", add: block_bytes) + + result + end, + timeout: timeout + ) + |> Stream.flat_map(fn + {:ok, entries} -> entries + _ -> [] + end) + |> Enum.to_list() + + {:done, result} end) - # The next bit is the only way i could figure out how to - # call complete once the stream was realized - |> Stream.transform( - fn -> nil end, - fn chunk_items, acc -> - # By the chunk items list directly, each transformation - # will flatten the resulting steam - {chunk_items, acc} - end, - fn _acc -> Progress.complete(token) end - ) else [] end diff --git a/apps/engine/test/engine/build/state_test.exs b/apps/engine/test/engine/build/state_test.exs index 6d024ae8..227bf54b 100644 --- a/apps/engine/test/engine/build/state_test.exs +++ b/apps/engine/test/engine/build/state_test.exs @@ -11,6 +11,13 @@ defmodule Engine.Build.StateTest do use Patch setup do + # Mock erpc calls for progress reporting + patch(Engine.Dispatch, :erpc_call, fn Expert.Progress, :begin, [_title, _opts] -> + {:ok, System.unique_integer([:positive])} + end) + + patch(Engine.Dispatch, :erpc_cast, fn Expert.Progress, _function, _args -> true end) + start_supervised!(Engine.Dispatch) start_supervised!(Engine.Api.Proxy) start_supervised!(Build.CaptureServer) @@ -70,7 +77,7 @@ defmodule Engine.Build.StateTest do patch(Build.Project, :compile, :ok) # Patch Progress and building_label to avoid side effects during state tests patch(State, :building_label, "Building test") - patch(Engine.Progress, :begin, fn _, _ -> 0 end) + patch(Engine.Progress, :begin, fn _, _ -> {:ok, 0} end) patch(Engine.Progress, :complete, fn _, _ -> :ok end) :ok end diff --git a/apps/engine/test/engine/dispatch/handlers/indexer_test.exs b/apps/engine/test/engine/dispatch/handlers/indexer_test.exs index ed3cba97..74cfa8e7 100644 --- a/apps/engine/test/engine/dispatch/handlers/indexer_test.exs +++ b/apps/engine/test/engine/dispatch/handlers/indexer_test.exs @@ -21,6 +21,12 @@ defmodule Engine.Dispatch.Handlers.IndexingTest do # Mock the broadcast so progress reporting doesn't fail patch(Engine.Api.Proxy, :broadcast, fn _ -> :ok end) + # Mock erpc calls for progress reporting + patch(Engine.Dispatch, :erpc_call, fn Expert.Progress, :begin, [_title, _opts] -> + {:ok, System.unique_integer([:positive])} + end) + + patch(Engine.Dispatch, :erpc_cast, fn Expert.Progress, _function, _args -> true end) start_supervised!(Engine.Dispatch) start_supervised!(Commands.Reindex) diff --git a/apps/engine/test/engine/progress_test.exs b/apps/engine/test/engine/progress_test.exs index 37a3a150..455065b7 100644 --- a/apps/engine/test/engine/progress_test.exs +++ b/apps/engine/test/engine/progress_test.exs @@ -8,15 +8,15 @@ defmodule Engine.ProgressTest do setup do test_pid = self() - # Mock rpc_call for begin - returns {:ok, token} - patch(Dispatch, :rpc_call, fn Expert.Progress, :begin, [title, opts] -> + # Mock erpc_call for begin - returns {:ok, token} + patch(Dispatch, :erpc_call, fn Expert.Progress, :begin, [title, opts] -> token = System.unique_integer([:positive]) send(test_pid, {:begin, token, title, opts}) {:ok, token} end) - # Mock rpc_cast for report and complete - patch(Dispatch, :rpc_cast, fn Expert.Progress, function, args -> + # Mock erpc_cast for report and complete + patch(Dispatch, :erpc_cast, fn Expert.Progress, function, args -> send(test_pid, {function, args}) true end) @@ -85,4 +85,140 @@ defmodule Engine.ProgressTest do assert opts[:message] == "Starting..." assert opts[:percentage] == 0 end + + describe "with_tracked_progress/3" do + test "tracks progress via GenServer and reports percentage" do + result = + Progress.with_tracked_progress("Indexing", 100, fn report -> + report.(message: "Processing", add: 25) + report.(message: "Processing", add: 25) + report.(message: "Processing", add: 50) + {:done, :indexed} + end) + + assert result == :indexed + assert_received {:begin, token, "Indexing", [percentage: 0]} when is_integer(token) + assert_received {:report, [^token, [message: "Processing", percentage: 25]]} + assert_received {:report, [^token, [message: "Processing", percentage: 50]]} + assert_received {:report, [^token, [message: "Processing", percentage: 100]]} + assert_received {:complete, [^token, []]} + end + + test "handles concurrent updates from multiple tasks" do + result = + Progress.with_tracked_progress("Concurrent", 100, fn report -> + 1..10 + |> Task.async_stream(fn i -> + report.(message: "Task #{i}", add: 10) + i + end) + |> Enum.map(fn {:ok, i} -> i end) + |> then(&{:done, &1}) + end) + + assert result == Enum.to_list(1..10) + assert_received {:begin, token, "Concurrent", [percentage: 0]} when is_integer(token) + # Should receive 10 report messages (order may vary due to concurrency) + for _ <- 1..10 do + assert_received {:report, [^token, [message: _, percentage: _]]} + end + + assert_received {:complete, [^token, []]} + end + + test "completes with final message" do + result = + Progress.with_tracked_progress("WithMessage", 10, fn report -> + report.(message: "Working", add: 10) + {:done, :success, "All done!"} + end) + + assert result == :success + assert_received {:begin, token, "WithMessage", [percentage: 0]} when is_integer(token) + assert_received {:complete, [^token, [message: "All done!"]]} + end + + test "handles cancel result" do + result = + Progress.with_tracked_progress("Cancellable", 100, fn _report -> + {:cancel, :stopped} + end) + + assert result == :stopped + assert_received {:begin, token, "Cancellable", [percentage: 0]} when is_integer(token) + assert_received {:complete, [^token, [message: "Cancelled"]]} + end + + test "cleans up tracker on exception" do + assert_raise RuntimeError, "oops", fn -> + Progress.with_tracked_progress("Failing", 100, fn _report -> + raise "oops" + end) + end + + assert_received {:begin, token, "Failing", [percentage: 0]} when is_integer(token) + assert_received {:complete, [^token, [message: "Error: oops"]]} + end + + test "caps percentage at 100 when add exceeds total" do + result = + Progress.with_tracked_progress("Overflow", 50, fn report -> + report.(message: "Big chunk", add: 100) + {:done, :ok} + end) + + assert result == :ok + assert_received {:begin, token, "Overflow", [percentage: 0]} when is_integer(token) + assert_received {:report, [^token, [message: "Big chunk", percentage: 100]]} + end + end + + describe "with_tracked_progress/4 with custom report function" do + test "uses custom report callback" do + test_pid = self() + + custom_report = fn message, current, total, token -> + send(test_pid, {:custom_report, message, current, total, token}) + end + + result = + Progress.with_tracked_progress( + "Custom", + 10, + fn report -> + report.(message: "Step 1", add: 3) + report.(message: "Step 2", add: 7) + {:done, :customized} + end, + custom_report + ) + + assert result == :customized + assert_received {:begin, token, "Custom", [percentage: 0]} when is_integer(token) + assert_received {:custom_report, "Step 1", 3, 10, ^token} + assert_received {:custom_report, "Step 2", 10, 10, ^token} + assert_received {:complete, [^token, []]} + end + + test "custom report receives nil message when not provided" do + test_pid = self() + + custom_report = fn message, current, total, token -> + send(test_pid, {:custom_report, message, current, total, token}) + end + + Progress.with_tracked_progress( + "NoMessage", + 10, + fn report -> + report.(add: 5) + {:done, :ok} + end, + custom_report + ) + + assert_received {:begin, token, "NoMessage", [percentage: 0]} when is_integer(token) + assert_received {:custom_report, nil, 5, 10, ^token} + end + end end diff --git a/apps/engine/test/engine/search/indexer_test.exs b/apps/engine/test/engine/search/indexer_test.exs index f74e7410..94ab135a 100644 --- a/apps/engine/test/engine/search/indexer_test.exs +++ b/apps/engine/test/engine/search/indexer_test.exs @@ -28,6 +28,12 @@ defmodule Engine.Search.IndexerTest do start_supervised(Dispatch) # Mock the broadcast so progress reporting doesn't fail patch(Engine.Api.Proxy, :broadcast, fn _ -> :ok end) + # Mock erpc calls for progress reporting + patch(Dispatch, :erpc_call, fn Expert.Progress, :begin, [_title, _opts] -> + {:ok, System.unique_integer([:positive])} + end) + + patch(Dispatch, :erpc_cast, fn Expert.Progress, _function, _args -> true end) {:ok, project: project} end diff --git a/apps/expert/lib/expert/progress.ex b/apps/expert/lib/expert/progress.ex index 611a6292..10dc6648 100644 --- a/apps/expert/lib/expert/progress.ex +++ b/apps/expert/lib/expert/progress.ex @@ -1,46 +1,17 @@ defmodule Expert.Progress do @moduledoc """ - Stateless progress reporting for LSP work-done progress. - - This module provides a simple API for reporting progress to the language client. - It is stateless - callers are responsible for managing their own tokens. When a - request handler process dies (e.g., due to cancellation), any tokens it held - naturally go away without explicit cleanup. - - ## Server-initiated progress - - {:ok, token} = Progress.begin("Building project") - Progress.report(token, message: "Compiling...") - Progress.complete(token, message: "Done") - - Or use the convenience wrapper: - - Progress.with_progress("Building", fn token -> - Progress.report(token, message: "Working...") - {:done, result} - end) - - ## Client-initiated progress - - When the client provides a workDoneToken with a request: - - Progress.with_client_progress(client_token, fn token -> - Progress.report(token, message: "Processing...") - {:done, result} - end) + LSP progress reporting for the Expert language server. """ + use Forge.Progress + alias Expert.Configuration alias Expert.Protocol.Id alias GenLSP.{Notifications, Requests, Structures} require Logger - @type token :: integer() | String.t() - @type work_result :: {:done, term()} | {:done, term(), String.t()} | {:cancel, term()} - @type work_fn :: (token() -> work_result()) - - defguardp is_token(token) when is_binary(token) or is_integer(token) + # Behaviour implementations @doc """ Begins server-initiated progress. @@ -61,7 +32,7 @@ defmodule Expert.Progress do {:ok, token} = Progress.begin("Indexing", message: "Starting...", percentage: 0) {:ok, token} = Progress.begin("Custom", token: my_unique_token) """ - @spec begin(String.t(), keyword()) :: {:ok, integer()} | {:error, :rejected} + @impl Forge.Progress def begin(title, opts \\ []) do opts = Keyword.validate!(opts, [:message, :percentage, :cancellable, :token]) @@ -95,14 +66,13 @@ defmodule Expert.Progress do Progress.report(token, message: "Processing file 1...") Progress.report(token, message: "Halfway there", percentage: 50) """ - @spec report(token(), keyword()) :: :ok + @impl Forge.Progress def report(token, opts \\ []) def report(-1, _opts), do: :ok def report(token, opts) when is_token(token) do notify_report(token, opts) - :ok end @@ -118,7 +88,7 @@ defmodule Expert.Progress do Progress.complete(token) Progress.complete(token, message: "Build complete") """ - @spec complete(token(), keyword()) :: :ok + @impl Forge.Progress def complete(token, opts \\ []) def complete(-1, _opts), do: :ok @@ -128,78 +98,7 @@ defmodule Expert.Progress do :ok end - @doc """ - Wraps a function with server-initiated progress reporting. - - The function receives the progress token and can call `Progress.report/2` directly. - - ## Return values - - * `{:done, result}` - Operation completed successfully - * `{:done, result, message}` - Completed with a final message - * `{:cancel, result}` - Operation was cancelled - - ## Options - - * `:message` - Initial status message (optional) - * `:percentage` - Initial percentage 0-100 (optional) - * `:cancellable` - Whether the client can cancel (default: false) - - ## Examples - - Progress.with_progress("Building", fn token -> - Progress.report(token, message: "Compiling...") - {:done, :ok, "Build complete"} - end) - """ - @spec with_progress(String.t(), work_fn(), keyword()) :: term() - def with_progress(title, func, opts \\ []) when is_function(func, 1) do - case begin(title, opts) do - {:ok, token} -> - run_work(token, func) - - {:error, :rejected} -> - # Client rejected the progress token, but we still run the work - # Just pass a dummy token that won't send notifications - case func.(0) do - {:done, result} -> result - {:done, result, _message} -> result - {:cancel, result} -> result - end - end - end - - @doc """ - Wraps a function with client-initiated progress reporting. - - Similar to `with_progress/3` but uses a token provided by the client. - """ - @spec with_client_progress(token(), work_fn()) :: term() - def with_client_progress(token, func) when is_function(func, 1) and is_token(token) do - run_work(token, func) - end - - defp run_work(token, func) do - try do - case func.(token) do - {:done, result} -> - complete(token) - result - - {:done, result, message} -> - complete(token, message: message) - result - - {:cancel, result} -> - complete(token, message: "Cancelled") - result - end - rescue - e -> - complete(token, message: "Error: #{Exception.message(e)}") - reraise e, __STACKTRACE__ - end - end + # Private helpers defp request_work_done_progress(token) do Expert.get_lsp() diff --git a/apps/expert/test/expert/code_intelligence/completion/builder_test.exs b/apps/expert/test/expert/code_intelligence/completion/builder_test.exs index 66552733..529a7ddd 100644 --- a/apps/expert/test/expert/code_intelligence/completion/builder_test.exs +++ b/apps/expert/test/expert/code_intelligence/completion/builder_test.exs @@ -2,6 +2,9 @@ defmodule Expert.CodeIntelligence.Completion.BuilderTest do alias Expert.CodeIntelligence.Completion.SortScope alias Forge.Ast alias Forge.Ast.Env + alias Forge.Document + alias Forge.Document.Position + alias Forge.Protocol.Convertible alias GenLSP.Structures.CompletionItem use ExUnit.Case, async: true @@ -82,4 +85,46 @@ defmodule Expert.CodeIntelligence.Completion.BuilderTest do |> snippet("", label: "") end end + + describe "non-ascii line range clamp" do + test "plain_text clamps start_char >= 1 and serializes on non-ASCII line" do + doc = Document.new("file:///builder_test.ex", "⚠️ hello", 0) + pos = Position.new(doc, 1, 1) + + env = %Env{document: doc, position: pos, prefix: "a"} + + item = plain_text(env, "X", label: "X") + + assert {:ok, lsp_text_edit_or_list} = Convertible.to_lsp(item.text_edit) + + lsp_edits = List.wrap(lsp_text_edit_or_list) + + refute Enum.empty?(lsp_edits) + + for %{range: %{start: %{character: start_ch}, end: %{character: end_ch}}} <- lsp_edits do + assert start_ch == 0 + assert end_ch == 0 + end + end + + test "snippet clamps start_char >= 1 and serializes on non-ASCII line" do + doc = Document.new("file:///builder_test.ex", "⚠️ hello", 0) + pos = Position.new(doc, 1, 1) + + env = %Env{document: doc, position: pos, prefix: "a"} + + item = snippet(env, "X$0", label: "X") + + assert {:ok, lsp_text_edit_or_list} = Convertible.to_lsp(item.text_edit) + + lsp_edits = List.wrap(lsp_text_edit_or_list) + + refute Enum.empty?(lsp_edits) + + for %{range: %{start: %{character: start_ch}, end: %{character: end_ch}}} <- lsp_edits do + assert start_ch == 0 + assert end_ch == 0 + end + end + end end diff --git a/apps/expert/test/expert/progress_test.exs b/apps/expert/test/expert/progress_test.exs index 01425f3d..7f9072fb 100644 --- a/apps/expert/test/expert/progress_test.exs +++ b/apps/expert/test/expert/progress_test.exs @@ -177,27 +177,6 @@ defmodule Expert.ProgressTest do end end - describe "with_client_progress/2" do - test "uses client-provided token" do - client_token = "client-token-123" - - result = - Progress.with_client_progress(client_token, fn token -> - assert token == client_token - {:done, :ok} - end) - - assert result == :ok - - # Should NOT request token creation (client already did) - refute_received {:request, _} - - # Should send end notification with client token - assert_received {:notify, %Notifications.DollarProgress{params: params}} - assert params.token == client_token - end - end - describe "when client does not support progress" do setup do patch(Expert.Configuration, :client_supports?, fn :work_done_progress -> false end) diff --git a/apps/forge/lib/forge/progress.ex b/apps/forge/lib/forge/progress.ex new file mode 100644 index 00000000..e7dea132 --- /dev/null +++ b/apps/forge/lib/forge/progress.ex @@ -0,0 +1,201 @@ +defmodule Forge.Progress do + @moduledoc """ + Behaviour and shared implementations for progress reporting. + + This module defines callbacks for progress reporting and provides shared + implementations of `with_progress` and `with_tracked_progress` that work + with any module implementing the behaviour. + + ## Implementing the behaviour + + defmodule MyProgress do + use Forge.Progress + + @impl Forge.Progress + def begin(title, opts), do: # ... + + @impl Forge.Progress + def report(token, opts), do: # ... + + @impl Forge.Progress + def complete(token, opts), do: # ... + end + + The `use Forge.Progress` macro automatically: + - Sets `@behaviour Forge.Progress` + - Defines `with_progress/2`, `with_progress/3` + - Defines `with_tracked_progress/3`, `with_tracked_progress/4` + """ + + defmacro __using__(_opts) do + quote do + @behaviour Forge.Progress + + alias Forge.Progress.Tracker + + defguardp is_token(token) when is_binary(token) or is_integer(token) + + @doc """ + Wraps work with progress reporting. + + The `work_fn` receives the progress token and should return one of: + - `{:done, result}` - Operation completed successfully + - `{:done, result, message}` - Completed with a final message + - `{:cancel, result}` - Operation was cancelled + + ## Options + + - `:message` - Initial status message (optional) + - `:percentage` - Initial percentage 0-100 (optional) + - `:cancellable` - Whether the client can cancel (default: false) + """ + def with_progress(title, work_fn, opts \\ []) when is_function(work_fn, 1) do + opts = Keyword.validate!(opts, [:message, :percentage, :cancellable]) + + case begin(title, opts) do + {:ok, token} -> + try do + case work_fn.(token) do + {:done, result} -> + complete(token, []) + result + + {:done, result, message} -> + complete(token, message: message) + result + + {:cancel, result} -> + complete(token, message: "Cancelled") + result + end + rescue + e -> + complete(token, message: "Error: #{Exception.message(e)}") + reraise e, __STACKTRACE__ + end + + {:error, :rejected} -> + case work_fn.(0) do + {:done, result} -> result + {:done, result, _message} -> result + {:cancel, result} -> result + end + end + end + + @doc """ + Wraps work with tracked progress reporting via an ephemeral GenServer. + + This is useful when you need to track progress across concurrent tasks. + The GenServer safely handles concurrent updates and fires a callback on each update. + + The work function receives a `report` function that accepts: + - `:message` - Status message + - `:add` - Amount to increment the counter + + Uses a default callback that reports percentage-based progress. + """ + def with_tracked_progress(title, total, work_fn) do + with_tracked_progress(title, total, work_fn, fn message, current, total, token -> + percentage = if total > 0, do: min(100, div(current * 100, total)), else: 0 + report(token, message: message, percentage: percentage) + end) + end + + @doc """ + Wraps work with tracked progress reporting using a custom report callback. + + The `report_fn` callback is invoked on each update with: + - `message` - The status message (or nil) + - `current` - The current progress value + - `total` - The total value representing 100% + - `token` - The progress token for reporting to LSP + """ + def with_tracked_progress(title, total, work_fn, report_fn) + when is_function(work_fn, 1) and is_function(report_fn, 4) do + case begin(title, percentage: 0) do + {:ok, token} -> + {:ok, tracker} = Tracker.start_link(token: token, total: total, report_fn: report_fn) + + report_update = fn opts -> + delta = Keyword.get(opts, :add, 0) + Tracker.add(tracker, delta, opts) + end + + try do + case work_fn.(report_update) do + {:done, result} -> + complete(token, []) + result + + {:done, result, message} -> + complete(token, message: message) + result + + {:cancel, result} -> + complete(token, message: "Cancelled") + result + end + rescue + e -> + complete(token, message: "Error: #{Exception.message(e)}") + reraise e, __STACKTRACE__ + after + Tracker.stop(tracker) + end + + {:error, :rejected} -> + case work_fn.(fn _opts -> :ok end) do + {:done, result} -> result + {:done, result, _message} -> result + {:cancel, result} -> result + end + end + end + + defoverridable with_progress: 2, + with_progress: 3, + with_tracked_progress: 3, + with_tracked_progress: 4 + end + end + + @type token :: integer() | String.t() + @type work_result :: {:done, term()} | {:done, term(), String.t()} | {:cancel, term()} + @type work_fn :: (token() -> work_result()) + @type tracked_work_fn :: ((keyword() -> :ok) -> work_result()) + @type report_callback :: (String.t() | nil, non_neg_integer(), pos_integer(), token() -> any()) + + @doc """ + Begins a progress sequence with the given title. + + Returns `{:ok, token}` on success or `{:error, :rejected}` if the client rejects the progress request. + + ## Options + + - `:message` - Initial status message + - `:percentage` - Initial percentage 0-100 + - `:cancellable` - Whether the client can cancel + - `:token` - Custom token to use (caller ensures uniqueness) + """ + @callback begin(title :: String.t(), opts :: keyword()) :: {:ok, token()} | {:error, :rejected} + + @doc """ + Reports progress for an in-progress operation. + + ## Options + + - `:message` - Status message to display + - `:percentage` - Progress percentage 0-100 + """ + @callback report(token :: token(), opts :: keyword()) :: :ok + + @doc """ + Completes a progress sequence. + + ## Options + + - `:message` - Final completion message + """ + @callback complete(token :: token(), opts :: keyword()) :: :ok +end diff --git a/apps/forge/lib/forge/progress/tracker.ex b/apps/forge/lib/forge/progress/tracker.ex new file mode 100644 index 00000000..ed1334bb --- /dev/null +++ b/apps/forge/lib/forge/progress/tracker.ex @@ -0,0 +1,67 @@ +defmodule Forge.Progress.Tracker do + @moduledoc """ + Ephemeral GenServer for tracking progress across concurrent tasks. + + This module provides a stateful progress tracker that can be safely + updated from multiple concurrent processes (e.g., Task.async_stream). + It fires a callback on each update to report progress to the LSP client. + + Use via `Forge.Progress.with_tracked_progress/4,5` rather than directly. + """ + + use GenServer + + defstruct [:token, :total, :current, :report_fn] + + # Client API + + @doc """ + Starts a tracker process. + + ## Options + + - `:token` - The progress token (required) + - `:total` - The total value representing 100% (required) + - `:report_fn` - Callback invoked on each update (required) + Signature: `(message, current, total, token) -> any()` + """ + def start_link(opts), do: GenServer.start_link(__MODULE__, opts) + + @doc """ + Adds delta to the current progress and fires the report callback. + + ## Options + + - `:message` - Status message to pass to the callback + """ + def add(tracker, delta, opts \\ []), do: GenServer.cast(tracker, {:add, delta, opts}) + + @doc """ + Stops the tracker process. + """ + def stop(tracker), do: GenServer.stop(tracker, :normal) + + # Server callbacks + + @impl GenServer + def init(opts) do + state = %__MODULE__{ + token: Keyword.fetch!(opts, :token), + total: Keyword.fetch!(opts, :total), + current: 0, + report_fn: Keyword.fetch!(opts, :report_fn) + } + + {:ok, state} + end + + @impl GenServer + def handle_cast({:add, delta, opts}, state) do + new_current = state.current + delta + message = Keyword.get(opts, :message) + + state.report_fn.(message, new_current, state.total, state.token) + + {:noreply, %{state | current: new_current}} + end +end From b91d51fead0be3c97009730eec6c7be520402fa5 Mon Sep 17 00:00:00 2001 From: Moosieus Date: Sat, 13 Dec 2025 13:30:45 -0500 Subject: [PATCH 06/17] make stuff more self-documenting --- apps/engine/lib/engine/dispatch.ex | 6 - apps/expert/lib/expert/progress.ex | 74 ++++------- apps/forge/lib/forge/progress.ex | 206 +++++++++-------------------- 3 files changed, 95 insertions(+), 191 deletions(-) diff --git a/apps/engine/lib/engine/dispatch.ex b/apps/engine/lib/engine/dispatch.ex index 01c01fdc..67b2c4fb 100644 --- a/apps/engine/lib/engine/dispatch.ex +++ b/apps/engine/lib/engine/dispatch.ex @@ -67,18 +67,12 @@ defmodule Engine.Dispatch do {:local, __MODULE__} end - @doc """ - :rpc.call to the server node. - """ def erpc_call(module, function, args) do project = Engine.get_project() manager_node = Project.manager_node_name(project) :erpc.call(manager_node, module, function, args, 1_000) end - @doc """ - :rpc.cast to the server node. - """ def erpc_cast(module, function, args) do project = Engine.get_project() manager_node = Project.manager_node_name(project) diff --git a/apps/expert/lib/expert/progress.ex b/apps/expert/lib/expert/progress.ex index 10dc6648..2370342e 100644 --- a/apps/expert/lib/expert/progress.ex +++ b/apps/expert/lib/expert/progress.ex @@ -11,6 +11,8 @@ defmodule Expert.Progress do require Logger + @noop_token -1 + # Behaviour implementations @doc """ @@ -41,7 +43,7 @@ defmodule Expert.Progress do if Configuration.client_supports?(:work_done_progress) do case request_work_done_progress(token) do :ok -> - notify_begin(token, title, opts) + notify(token, progress_begin(title, opts)) {:ok, token} {:error, reason} -> @@ -49,7 +51,7 @@ defmodule Expert.Progress do {:error, :rejected} end else - {:ok, -1} + {:ok, @noop_token} end end @@ -69,10 +71,10 @@ defmodule Expert.Progress do @impl Forge.Progress def report(token, opts \\ []) - def report(-1, _opts), do: :ok + def report(@noop_token, _opts), do: :ok def report(token, opts) when is_token(token) do - notify_report(token, opts) + notify(token, progress_report(opts)) :ok end @@ -91,10 +93,10 @@ defmodule Expert.Progress do @impl Forge.Progress def complete(token, opts \\ []) - def complete(-1, _opts), do: :ok + def complete(@noop_token, _opts), do: :ok def complete(token, opts) when is_token(token) do - notify_end(token, opts) + notify(token, progress_end(opts)) :ok end @@ -112,49 +114,31 @@ defmodule Expert.Progress do end end - defp notify_begin(token, title, opts) do - lsp = Expert.get_lsp() - - GenLSP.notify(lsp, %Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: token, - value: %Structures.WorkDoneProgressBegin{ - kind: "begin", - title: title, - message: Keyword.get(opts, :message), - percentage: Keyword.get(opts, :percentage), - cancellable: Keyword.get(opts, :cancellable) - } - } + defp notify(token, value) do + GenLSP.notify(Expert.get_lsp(), %Notifications.DollarProgress{ + params: %Structures.ProgressParams{token: token, value: value} }) end - defp notify_report(token, updates) do - lsp = Expert.get_lsp() - - GenLSP.notify(lsp, %Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: token, - value: %Structures.WorkDoneProgressReport{ - kind: "report", - message: Keyword.get(updates, :message), - percentage: Keyword.get(updates, :percentage) - } - } - }) + defp progress_begin(title, opts) do + %Structures.WorkDoneProgressBegin{ + kind: "begin", + title: title, + message: opts[:message], + percentage: opts[:percentage], + cancellable: opts[:cancellable] + } end - defp notify_end(token, opts) do - lsp = Expert.get_lsp() - - GenLSP.notify(lsp, %Notifications.DollarProgress{ - params: %Structures.ProgressParams{ - token: token, - value: %Structures.WorkDoneProgressEnd{ - kind: "end", - message: Keyword.get(opts, :message) - } - } - }) + defp progress_report(opts) do + %Structures.WorkDoneProgressReport{ + kind: "report", + message: opts[:message], + percentage: opts[:percentage] + } + end + + defp progress_end(opts) do + %Structures.WorkDoneProgressEnd{kind: "end", message: opts[:message]} end end diff --git a/apps/forge/lib/forge/progress.ex b/apps/forge/lib/forge/progress.ex index e7dea132..7bfea1e2 100644 --- a/apps/forge/lib/forge/progress.ex +++ b/apps/forge/lib/forge/progress.ex @@ -1,32 +1,31 @@ defmodule Forge.Progress do @moduledoc """ - Behaviour and shared implementations for progress reporting. + Behaviour for progress reporting. - This module defines callbacks for progress reporting and provides shared - implementations of `with_progress` and `with_tracked_progress` that work - with any module implementing the behaviour. - - ## Implementing the behaviour + ## Usage defmodule MyProgress do use Forge.Progress - @impl Forge.Progress - def begin(title, opts), do: # ... + @impl true + def begin(title, opts), do: ... - @impl Forge.Progress - def report(token, opts), do: # ... + @impl true + def report(token, opts), do: ... - @impl Forge.Progress - def complete(token, opts), do: # ... + @impl true + def complete(token, opts), do: ... end - - The `use Forge.Progress` macro automatically: - - Sets `@behaviour Forge.Progress` - - Defines `with_progress/2`, `with_progress/3` - - Defines `with_tracked_progress/3`, `with_tracked_progress/4` """ + @type token :: integer() | String.t() + @noop_token -1 + @type work_result :: {:done, term()} | {:done, term(), String.t()} | {:cancel, term()} + + @callback begin(title :: String.t(), opts :: keyword()) :: {:ok, token()} | {:error, :rejected} + @callback report(token :: token(), opts :: keyword()) :: :ok + @callback complete(token :: token(), opts :: keyword()) :: :ok + defmacro __using__(_opts) do quote do @behaviour Forge.Progress @@ -50,44 +49,14 @@ defmodule Forge.Progress do - `:cancellable` - Whether the client can cancel (default: false) """ def with_progress(title, work_fn, opts \\ []) when is_function(work_fn, 1) do - opts = Keyword.validate!(opts, [:message, :percentage, :cancellable]) - - case begin(title, opts) do - {:ok, token} -> - try do - case work_fn.(token) do - {:done, result} -> - complete(token, []) - result - - {:done, result, message} -> - complete(token, message: message) - result - - {:cancel, result} -> - complete(token, message: "Cancelled") - result - end - rescue - e -> - complete(token, message: "Error: #{Exception.message(e)}") - reraise e, __STACKTRACE__ - end - - {:error, :rejected} -> - case work_fn.(0) do - {:done, result} -> result - {:done, result, _message} -> result - {:cancel, result} -> result - end - end + run_with_progress(title, opts, work_fn) end @doc """ Wraps work with tracked progress reporting via an ephemeral GenServer. - This is useful when you need to track progress across concurrent tasks. - The GenServer safely handles concurrent updates and fires a callback on each update. + Safely handles concurrent updates and fires a callback on each update. + Useful when you need to track progress across concurrent tasks. The work function receives a `report` function that accepts: - `:message` - Status message @@ -95,107 +64,64 @@ defmodule Forge.Progress do Uses a default callback that reports percentage-based progress. """ - def with_tracked_progress(title, total, work_fn) do - with_tracked_progress(title, total, work_fn, fn message, current, total, token -> - percentage = if total > 0, do: min(100, div(current * 100, total)), else: 0 - report(token, message: message, percentage: percentage) - end) + def with_tracked_progress(title, total, work_fn) when is_function(work_fn, 1) do + with_tracked_progress(title, total, work_fn, &default_report/4) end - @doc """ - Wraps work with tracked progress reporting using a custom report callback. - - The `report_fn` callback is invoked on each update with: - - `message` - The status message (or nil) - - `current` - The current progress value - - `total` - The total value representing 100% - - `token` - The progress token for reporting to LSP - """ def with_tracked_progress(title, total, work_fn, report_fn) when is_function(work_fn, 1) and is_function(report_fn, 4) do - case begin(title, percentage: 0) do - {:ok, token} -> - {:ok, tracker} = Tracker.start_link(token: token, total: total, report_fn: report_fn) - - report_update = fn opts -> - delta = Keyword.get(opts, :add, 0) - Tracker.add(tracker, delta, opts) - end - - try do - case work_fn.(report_update) do - {:done, result} -> - complete(token, []) - result - - {:done, result, message} -> - complete(token, message: message) - result - - {:cancel, result} -> - complete(token, message: "Cancelled") - result - end - rescue - e -> - complete(token, message: "Error: #{Exception.message(e)}") - reraise e, __STACKTRACE__ - after - Tracker.stop(tracker) - end - - {:error, :rejected} -> - case work_fn.(fn _opts -> :ok end) do - {:done, result} -> result - {:done, result, _message} -> result - {:cancel, result} -> result - end + run_with_progress(title, [percentage: 0], fn token -> + {:ok, tracker} = Tracker.start_link(token: token, total: total, report_fn: report_fn) + + try do + work_fn.(fn opts -> Tracker.add(tracker, Keyword.get(opts, :add, 0), opts) end) + after + Tracker.stop(tracker) + end + end) + end + + defp run_with_progress(title, opts, work_fn) do + case begin(title, opts) do + {:ok, token} -> execute_work(token, work_fn) + {:error, :rejected} -> elem(work_fn.(@noop_token), 1) + end + end + + defp execute_work(token, work_fn) do + try do + work_fn.(token) |> complete_with(token) + rescue + e -> + complete(token, message: "Error: #{Exception.message(e)}") + reraise e, __STACKTRACE__ end end + defp complete_with({:done, result}, token) do + complete(token, []) + result + end + + defp complete_with({:done, result, msg}, token) do + complete(token, message: msg) + result + end + + defp complete_with({:cancel, result}, token) do + complete(token, message: "Cancelled") + result + end + + defp default_report(message, current, total, token) do + percentage = if total > 0, do: min(100, div(current * 100, total)), else: 0 + report(token, message: message, percentage: percentage) + end + defoverridable with_progress: 2, with_progress: 3, with_tracked_progress: 3, with_tracked_progress: 4 end end - - @type token :: integer() | String.t() - @type work_result :: {:done, term()} | {:done, term(), String.t()} | {:cancel, term()} - @type work_fn :: (token() -> work_result()) - @type tracked_work_fn :: ((keyword() -> :ok) -> work_result()) - @type report_callback :: (String.t() | nil, non_neg_integer(), pos_integer(), token() -> any()) - - @doc """ - Begins a progress sequence with the given title. - - Returns `{:ok, token}` on success or `{:error, :rejected}` if the client rejects the progress request. - - ## Options - - - `:message` - Initial status message - - `:percentage` - Initial percentage 0-100 - - `:cancellable` - Whether the client can cancel - - `:token` - Custom token to use (caller ensures uniqueness) - """ - @callback begin(title :: String.t(), opts :: keyword()) :: {:ok, token()} | {:error, :rejected} - - @doc """ - Reports progress for an in-progress operation. - - ## Options - - - `:message` - Status message to display - - `:percentage` - Progress percentage 0-100 - """ - @callback report(token :: token(), opts :: keyword()) :: :ok - - @doc """ - Completes a progress sequence. - - ## Options - - - `:message` - Final completion message - """ - @callback complete(token :: token(), opts :: keyword()) :: :ok end From 24f7a6c094b24b6ed9ae0099c71a8832ed6ea064 Mon Sep 17 00:00:00 2001 From: Moosieus Date: Sat, 13 Dec 2025 13:42:02 -0500 Subject: [PATCH 07/17] remove unused proxy messages --- apps/engine/lib/engine/api/proxy.ex | 17 +---------------- apps/engine/test/engine/api/proxy_test.exs | 16 ---------------- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/apps/engine/lib/engine/api/proxy.ex b/apps/engine/lib/engine/api/proxy.ex index f181f928..ef36ce9c 100644 --- a/apps/engine/lib/engine/api/proxy.ex +++ b/apps/engine/lib/engine/api/proxy.ex @@ -12,8 +12,7 @@ defmodule Engine.Api.Proxy do The logic follows below `broadcast` - Buffered - Though, those related to other events, like compilation are subject to - the rules that govern their source events. Progress messages are sent regardless of - buffering. + the rules that govern their source events. `schedule_compile` - Buffered - Only one call is kept `compile_document` - Buffered, though only one call per URI is kept, and if a `schedule_compile` call was buffered, all `compile_document` calls are dropped @@ -60,20 +59,6 @@ defmodule Engine.Api.Proxy do # proxied functions - # Progress messages bypass buffering to ensure timely progress updates - def broadcast({:engine_progress_begin, _, _, _} = message) do - Engine.Dispatch.broadcast(message) - end - - # report and complete are 3-tuples: {type, token, updates_or_opts} - def broadcast({:engine_progress_report, _, _} = message) do - Engine.Dispatch.broadcast(message) - end - - def broadcast({:engine_progress_complete, _, _} = message) do - Engine.Dispatch.broadcast(message) - end - def broadcast(message) do mfa = to_mfa(Engine.Dispatch.broadcast(message)) :gen_statem.call(__MODULE__, buffer(contents: mfa)) diff --git a/apps/engine/test/engine/api/proxy_test.exs b/apps/engine/test/engine/api/proxy_test.exs index 3cc68363..a4ab88ef 100644 --- a/apps/engine/test/engine/api/proxy_test.exs +++ b/apps/engine/test/engine/api/proxy_test.exs @@ -32,14 +32,6 @@ defmodule Engine.Api.ProxyTest do assert_called(Dispatch.broadcast(:hello)) end - test "proxies broadcasts of progress messages" do - patch(Dispatch, :broadcast, :ok) - progress_message = {:engine_progress_report, 123, [message: "testing"]} - assert :ok = Proxy.broadcast(progress_message) - - assert_called(Dispatch.broadcast(^progress_message)) - end - test "schedule compile is proxied", %{project: project} do patch(Build, :schedule_compile, :ok) assert :ok = Proxy.schedule_compile(true) @@ -151,14 +143,6 @@ defmodule Engine.Api.ProxyTest do assert {:error, {:already_buffering, _}} = Proxy.start_buffering() end - test "proxies broadcasts of progress messages" do - patch(Dispatch, :broadcast, :ok) - progress_message = {:engine_progress_begin, 123, "test", []} - assert :ok = Proxy.broadcast(progress_message) - - assert_called(Dispatch.broadcast(^progress_message)) - end - test "buffers broadcasts" do assert :ok = Proxy.broadcast(file_compile_requested()) refute_any_call(Dispatch.broadcast()) From f2dfe82bef2fab6738d874bb080a0e3cd4fbc030 Mon Sep 17 00:00:00 2001 From: Moosieus Date: Sat, 13 Dec 2025 14:01:04 -0500 Subject: [PATCH 08/17] move progress back into project module --- apps/engine/lib/engine/build.ex | 10 ++-- apps/engine/lib/engine/build/project.ex | 33 ++++++++---- apps/engine/lib/engine/build/state.ex | 68 ++++++++++--------------- apps/expert/lib/expert/progress.ex | 4 +- apps/forge/lib/forge/progress.ex | 6 ++- 5 files changed, 57 insertions(+), 64 deletions(-) diff --git a/apps/engine/lib/engine/build.ex b/apps/engine/lib/engine/build.ex index 19c3f6ab..3ab4ac81 100644 --- a/apps/engine/lib/engine/build.ex +++ b/apps/engine/lib/engine/build.ex @@ -1,7 +1,5 @@ defmodule Engine.Build do - alias Forge.Document - alias Forge.Project - + alias Forge.{Document, Project} alias Engine.Build.Document.Compilers.HEEx alias Engine.Build.State @@ -39,11 +37,9 @@ defmodule Engine.Build do # can't pass work token to Tracer module, so store it in persistent term. - def set_progress_token(%Project{} = project) do - :persistent_term.put({__MODULE__, :progress_token}, "build_engine:#{project.root_uri}") - end + def set_progress_token(token), do: :persistent_term.put({__MODULE__, :progress_token}, token) - def get_progress_token, do: :persistent_term.get({__MODULE__, :progress_token}) + def get_progress_token, do: :persistent_term.get({__MODULE__, :progress_token}, nil) def clear_progress_token, do: :persistent_term.erase({__MODULE__, :progress_token}) diff --git a/apps/engine/lib/engine/build/project.ex b/apps/engine/lib/engine/build/project.ex index 9153e1d1..65f88acc 100644 --- a/apps/engine/lib/engine/build/project.ex +++ b/apps/engine/lib/engine/build/project.ex @@ -7,14 +7,27 @@ defmodule Engine.Build.Project do require Logger def compile(%Project{} = project, initial?) do + Progress.with_progress("Building #{Project.display_name(project)}", fn token -> + Build.set_progress_token(token) + + try do + result = do_compile(project, initial?, token) + {:done, result} + after + Build.clear_progress_token() + end + end) + end + + defp do_compile(project, initial?, token) do Engine.Mix.in_project(fn _ -> Mix.Task.clear() - if initial?, do: prepare_for_project_build() + if initial?, do: prepare_for_project_build(token) compile_fun = fn -> Mix.Task.clear() - do_progress_report(message: "Building #{Project.display_name(project)}") + Progress.report(token, message: "Compiling #{Project.display_name(project)}") result = compile_in_isolation() Mix.Task.run(:loadpaths) result @@ -61,34 +74,32 @@ defmodule Engine.Build.Project do end end - defp prepare_for_project_build() do + defp prepare_for_project_build(token) do if connected_to_internet?() do - do_progress_report(message: "mix local.hex") + Progress.report(token, message: "mix local.hex") Mix.Task.run("local.hex", ~w(--force)) - do_progress_report(message: "mix local.rebar") + Progress.report(token, message: "mix local.rebar") Mix.Task.run("local.rebar", ~w(--force)) - do_progress_report(message: "mix deps.get") + Progress.report(token, message: "mix deps.get") Mix.Task.run("deps.get") else Logger.warning("Could not connect to hex.pm, dependencies will not be fetched") end - do_progress_report(message: "mix loadconfig") + Progress.report(token, message: "mix loadconfig") Mix.Task.run(:loadconfig) if not Elixir.Features.compile_keeps_current_directory?() do - do_progress_report(message: "mix deps.compile") + Progress.report(token, message: "mix deps.compile") Mix.Task.run("deps.safe_compile", ~w(--skip-umbrella-children)) end - do_progress_report(message: "Loading plugins") + Progress.report(token, message: "Loading plugins") Plugin.Discovery.run() end - defp do_progress_report(opts), do: Progress.report(Build.get_progress_token(), opts) - defp connected_to_internet? do # While there's no perfect way to check if a computer is connected to the internet, # it seems reasonable to gate pulling dependencies on a resolution check for hex.pm. diff --git a/apps/engine/lib/engine/build/state.ex b/apps/engine/lib/engine/build/state.ex index aff1b3e3..32768134 100644 --- a/apps/engine/lib/engine/build/state.ex +++ b/apps/engine/lib/engine/build/state.ex @@ -2,7 +2,6 @@ defmodule Engine.Build.State do alias Elixir.Features alias Engine.Build alias Engine.Plugin - alias Engine.Progress alias Forge.Document alias Forge.EngineApi.Messages alias Forge.Project @@ -82,56 +81,41 @@ defmodule Engine.Build.State do project = state.project Build.with_lock(fn -> - Build.set_progress_token(project) + compile_requested_message = + project_compile_requested(project: project, build_number: state.build_number) - {:ok, token} = - Progress.begin( - "Building #{Project.display_name(project)}", - token: Build.get_progress_token() - ) - - try do - compile_requested_message = - project_compile_requested(project: project, build_number: state.build_number) - - Engine.broadcast(compile_requested_message) - {elapsed_us, result} = :timer.tc(fn -> Build.Project.compile(project, initial?) end) - elapsed_ms = to_ms(elapsed_us) + Engine.broadcast(compile_requested_message) + {elapsed_us, result} = :timer.tc(fn -> Build.Project.compile(project, initial?) end) + elapsed_ms = to_ms(elapsed_us) - {compile_message, diagnostics} = - case result do - :ok -> - message = - project_compiled(status: :success, project: project, elapsed_ms: elapsed_ms) + {compile_message, diagnostics} = + case result do + :ok -> + message = project_compiled(status: :success, project: project, elapsed_ms: elapsed_ms) - {message, []} + {message, []} - {:ok, diagnostics} -> - message = - project_compiled(status: :success, project: project, elapsed_ms: elapsed_ms) + {:ok, diagnostics} -> + message = project_compiled(status: :success, project: project, elapsed_ms: elapsed_ms) - {message, List.wrap(diagnostics)} + {message, List.wrap(diagnostics)} - {:error, diagnostics} -> - message = project_compiled(status: :error, project: project, elapsed_ms: elapsed_ms) + {:error, diagnostics} -> + message = project_compiled(status: :error, project: project, elapsed_ms: elapsed_ms) - {message, List.wrap(diagnostics)} - end + {message, List.wrap(diagnostics)} + end - diagnostics_message = - project_diagnostics( - project: project, - build_number: state.build_number, - diagnostics: diagnostics - ) + diagnostics_message = + project_diagnostics( + project: project, + build_number: state.build_number, + diagnostics: diagnostics + ) - Engine.broadcast(compile_message) - Engine.broadcast(diagnostics_message) - Plugin.diagnose(project, state.build_number) - after - Build.clear_progress_token() - Progress.complete(token) - end + Engine.broadcast(compile_message) + Engine.broadcast(diagnostics_message) + Plugin.diagnose(project, state.build_number) end) state diff --git a/apps/expert/lib/expert/progress.ex b/apps/expert/lib/expert/progress.ex index 2370342e..ee9b1486 100644 --- a/apps/expert/lib/expert/progress.ex +++ b/apps/expert/lib/expert/progress.ex @@ -11,7 +11,7 @@ defmodule Expert.Progress do require Logger - @noop_token -1 + @noop_token Forge.Progress.noop_token() # Behaviour implementations @@ -40,7 +40,7 @@ defmodule Expert.Progress do token = opts[:token] || System.unique_integer([:positive]) - if Configuration.client_supports?(:work_done_progress) do + if Configuration.client_support(:work_done_progress) do case request_work_done_progress(token) do :ok -> notify(token, progress_begin(title, opts)) diff --git a/apps/forge/lib/forge/progress.ex b/apps/forge/lib/forge/progress.ex index 7bfea1e2..70e40931 100644 --- a/apps/forge/lib/forge/progress.ex +++ b/apps/forge/lib/forge/progress.ex @@ -19,9 +19,11 @@ defmodule Forge.Progress do """ @type token :: integer() | String.t() - @noop_token -1 @type work_result :: {:done, term()} | {:done, term(), String.t()} | {:cancel, term()} + @noop_token -1 + def noop_token, do: @noop_token + @callback begin(title :: String.t(), opts :: keyword()) :: {:ok, token()} | {:error, :rejected} @callback report(token :: token(), opts :: keyword()) :: :ok @callback complete(token :: token(), opts :: keyword()) :: :ok @@ -84,7 +86,7 @@ defmodule Forge.Progress do defp run_with_progress(title, opts, work_fn) do case begin(title, opts) do {:ok, token} -> execute_work(token, work_fn) - {:error, :rejected} -> elem(work_fn.(@noop_token), 1) + {:error, :rejected} -> elem(work_fn.(Forge.Progress.noop_token()), 1) end end From 3c7f1f522b7c556556ee533411a2b61c77b4d1d9 Mon Sep 17 00:00:00 2001 From: Moosieus Date: Sat, 13 Dec 2025 16:01:48 -0500 Subject: [PATCH 09/17] more detailed progress reporting --- apps/engine/lib/engine.ex | 5 ++++- apps/engine/lib/engine/application.ex | 3 +-- apps/engine/lib/engine/build/project.ex | 4 ++-- apps/engine/lib/engine/build/state.ex | 2 ++ apps/engine/lib/engine/progress.ex | 4 +--- apps/engine/test/engine/build/state_test.exs | 4 ---- apps/expert/lib/expert/engine_node.ex | 13 +++++++++---- apps/expert/lib/expert/progress.ex | 2 -- apps/expert/lib/expert/project/node.ex | 8 ++++---- apps/expert/test/expert/progress_test.exs | 4 ++-- 10 files changed, 25 insertions(+), 24 deletions(-) diff --git a/apps/engine/lib/engine.ex b/apps/engine/lib/engine.ex index afe3c4e8..ffc9e166 100644 --- a/apps/engine/lib/engine.ex +++ b/apps/engine/lib/engine.ex @@ -8,6 +8,7 @@ defmodule Engine do alias Engine.Api.Proxy alias Engine.CodeAction alias Engine.CodeIntelligence + alias Engine.Progress alias Forge.Project require Logger @@ -68,10 +69,12 @@ defmodule Engine do do: app end - def ensure_apps_started do + def ensure_apps_started(token \\ -1) do apps_to_start = [:elixir, :runtime_tools | @allowed_apps] Enum.reduce_while(apps_to_start, :ok, fn app_name, _ -> + Progress.report(token, message: "Starting #{app_name}...") + case :application.ensure_all_started(app_name) do {:ok, _} -> {:cont, :ok} error -> {:halt, error} diff --git a/apps/engine/lib/engine/application.ex b/apps/engine/lib/engine/application.ex index 395909f0..014aaf60 100644 --- a/apps/engine/lib/engine/application.ex +++ b/apps/engine/lib/engine/application.ex @@ -2,7 +2,6 @@ defmodule Engine.Application do @moduledoc false use Application - require Logger @impl true def start(_type, _args) do @@ -12,7 +11,7 @@ defmodule Engine.Application do Engine.Api.Proxy, Engine.Commands.Reindex, Engine.Module.Loader, - {Engine.Dispatch, progress: true}, + Engine.Dispatch, Engine.ModuleMappings, Engine.Build, Engine.Build.CaptureServer, diff --git a/apps/engine/lib/engine/build/project.ex b/apps/engine/lib/engine/build/project.ex index 65f88acc..0b359ea4 100644 --- a/apps/engine/lib/engine/build/project.ex +++ b/apps/engine/lib/engine/build/project.ex @@ -7,12 +7,12 @@ defmodule Engine.Build.Project do require Logger def compile(%Project{} = project, initial?) do + Logger.info("Building #{Project.display_name(project)}") Progress.with_progress("Building #{Project.display_name(project)}", fn token -> Build.set_progress_token(token) try do - result = do_compile(project, initial?, token) - {:done, result} + {:done, do_compile(project, initial?, token)} after Build.clear_progress_token() end diff --git a/apps/engine/lib/engine/build/state.ex b/apps/engine/lib/engine/build/state.ex index 32768134..a26df36a 100644 --- a/apps/engine/lib/engine/build/state.ex +++ b/apps/engine/lib/engine/build/state.ex @@ -80,6 +80,8 @@ defmodule Engine.Build.State do state = increment_build_number(state) project = state.project + Logger.info("Compiling project #{Project.display_name(project)}") + Build.with_lock(fn -> compile_requested_message = project_compile_requested(project: project, build_number: state.build_number) diff --git a/apps/engine/lib/engine/progress.ex b/apps/engine/lib/engine/progress.ex index 348db5b5..8c13523d 100644 --- a/apps/engine/lib/engine/progress.ex +++ b/apps/engine/lib/engine/progress.ex @@ -13,9 +13,7 @@ defmodule Engine.Progress do end @impl true - def report(token, opts \\ []) - - def report(token, opts) when is_token(token) do + def report(token, opts) when is_token(token) and is_list(opts) do Dispatch.erpc_cast(Expert.Progress, :report, [token, opts]) end diff --git a/apps/engine/test/engine/build/state_test.exs b/apps/engine/test/engine/build/state_test.exs index 227bf54b..2095ff5a 100644 --- a/apps/engine/test/engine/build/state_test.exs +++ b/apps/engine/test/engine/build/state_test.exs @@ -75,10 +75,6 @@ defmodule Engine.Build.StateTest do def with_patched_compilation(_) do patch(Build.Document, :compile, :ok) patch(Build.Project, :compile, :ok) - # Patch Progress and building_label to avoid side effects during state tests - patch(State, :building_label, "Building test") - patch(Engine.Progress, :begin, fn _, _ -> {:ok, 0} end) - patch(Engine.Progress, :complete, fn _, _ -> :ok end) :ok end diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index 5c7f5a73..78e4ffd8 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -133,7 +133,7 @@ defmodule Expert.EngineNode do alias Forge.Document use GenServer - def start(project) do + def start(project, token \\ nil) do start_net_kernel(project) node_name = Project.node_name(project) @@ -141,20 +141,25 @@ defmodule Expert.EngineNode do with {:ok, node_pid} <- EngineSupervisor.start_project_node(project), {:ok, glob_paths} <- glob_paths(project), + :ok <- report_progress(token, "Starting Erlang node..."), :ok <- start_node(project, glob_paths), + :ok <- report_progress(token, "Bootstrapping engine..."), :ok <- :rpc.call(node_name, Engine.Bootstrap, :init, bootstrap_args), - :ok <- ensure_apps_started(node_name) do + :ok <- ensure_apps_started(node_name, token) do {:ok, node_name, node_pid} end end + defp report_progress(nil, _message), do: :ok + defp report_progress(token, message), do: Expert.Progress.report(token, message: message) + defp start_net_kernel(%Project{} = project) do manager = Project.manager_node_name(project) Node.start(manager, :longnames) end - defp ensure_apps_started(node) do - :rpc.call(node, Engine, :ensure_apps_started, []) + defp ensure_apps_started(node, token) do + :rpc.call(node, Engine, :ensure_apps_started, [token]) end if Mix.env() == :test do diff --git a/apps/expert/lib/expert/progress.ex b/apps/expert/lib/expert/progress.ex index ee9b1486..a68ff90e 100644 --- a/apps/expert/lib/expert/progress.ex +++ b/apps/expert/lib/expert/progress.ex @@ -69,8 +69,6 @@ defmodule Expert.Progress do Progress.report(token, message: "Halfway there", percentage: 50) """ @impl Forge.Progress - def report(token, opts \\ []) - def report(@noop_token, _opts), do: :ok def report(token, opts) when is_token(token) do diff --git a/apps/expert/lib/expert/project/node.ex b/apps/expert/lib/expert/project/node.ex index e5d4c7ef..3dc05c02 100644 --- a/apps/expert/lib/expert/project/node.ex +++ b/apps/expert/lib/expert/project/node.ex @@ -50,8 +50,8 @@ defmodule Expert.Project.Node do @impl GenServer def init(%Project{} = project) do - Progress.with_progress("Starting project node", fn _token -> - result = start_node(project) + Progress.with_progress("Starting project node", fn token -> + result = start_node(project, token) {:done, result, "Project node started"} end) @@ -93,8 +93,8 @@ defmodule Expert.Project.Node do # private api - defp start_node(%Project{} = project) do - with {:ok, node, node_pid} <- EngineNode.start(project) do + defp start_node(%Project{} = project, token \\ -1) do + with {:ok, node, node_pid} <- EngineNode.start(project, token) do Node.monitor(node, true) {:ok, State.new(project, node, node_pid)} end diff --git a/apps/expert/test/expert/progress_test.exs b/apps/expert/test/expert/progress_test.exs index 7f9072fb..58f67592 100644 --- a/apps/expert/test/expert/progress_test.exs +++ b/apps/expert/test/expert/progress_test.exs @@ -12,7 +12,7 @@ defmodule Expert.ProgressTest do lsp = spawn(fn -> Process.sleep(:infinity) end) patch(Expert, :get_lsp, fn -> lsp end) - patch(Expert.Configuration, :client_supports?, fn :work_done_progress -> true end) + patch(Expert.Configuration, :client_support, fn :work_done_progress -> true end) # Mock GenLSP.request to return nil (success) and send the request to test process patch(GenLSP, :request, fn ^lsp, request -> @@ -179,7 +179,7 @@ defmodule Expert.ProgressTest do describe "when client does not support progress" do setup do - patch(Expert.Configuration, :client_supports?, fn :work_done_progress -> false end) + patch(Expert.Configuration, :client_support, fn :work_done_progress -> false end) :ok end From c96391c44a4be46adcf8c8d2898181480973b7fa Mon Sep 17 00:00:00 2001 From: Moosieus Date: Sun, 14 Dec 2025 11:22:13 -0500 Subject: [PATCH 10/17] centralize noop report token --- apps/engine/lib/engine.ex | 2 +- apps/engine/lib/engine/dispatch.ex | 14 +++++--------- apps/engine/lib/engine/progress.ex | 12 ++++++++++-- apps/engine/test/engine/build/state_test.exs | 7 ------- apps/expert/lib/expert/engine_node.ex | 10 ++++------ apps/expert/lib/expert/progress.ex | 2 -- apps/expert/lib/expert/project/node.ex | 2 +- apps/expert/test/expert/progress_test.exs | 7 +++---- apps/forge/lib/forge/progress.ex | 9 +++++---- 9 files changed, 29 insertions(+), 36 deletions(-) diff --git a/apps/engine/lib/engine.ex b/apps/engine/lib/engine.ex index ffc9e166..aaf6e773 100644 --- a/apps/engine/lib/engine.ex +++ b/apps/engine/lib/engine.ex @@ -69,7 +69,7 @@ defmodule Engine do do: app end - def ensure_apps_started(token \\ -1) do + def ensure_apps_started(token \\ Progress.noop_token()) do apps_to_start = [:elixir, :runtime_tools | @allowed_apps] Enum.reduce_while(apps_to_start, :ok, fn app_name, _ -> diff --git a/apps/engine/lib/engine/dispatch.ex b/apps/engine/lib/engine/dispatch.ex index 67b2c4fb..873bd285 100644 --- a/apps/engine/lib/engine/dispatch.ex +++ b/apps/engine/lib/engine/dispatch.ex @@ -63,19 +63,15 @@ defmodule Engine.Dispatch do } end - defp name do - {:local, __MODULE__} - end + defp name, do: {:local, __MODULE__} def erpc_call(module, function, args) do - project = Engine.get_project() - manager_node = Project.manager_node_name(project) - :erpc.call(manager_node, module, function, args, 1_000) + :erpc.call(manager_node(), module, function, args, 1_000) end def erpc_cast(module, function, args) do - project = Engine.get_project() - manager_node = Project.manager_node_name(project) - :erpc.cast(manager_node, module, function, args) + :erpc.cast(manager_node(), module, function, args) end + + defp manager_node(), do: Engine.get_project() |> Project.manager_node_name() end diff --git a/apps/engine/lib/engine/progress.ex b/apps/engine/lib/engine/progress.ex index 8c13523d..eee6734e 100644 --- a/apps/engine/lib/engine/progress.ex +++ b/apps/engine/lib/engine/progress.ex @@ -8,17 +8,25 @@ defmodule Engine.Progress do alias Engine.Dispatch @impl true - def begin(title, opts \\ []) do + def begin(title, opts \\ []) when is_list(opts) do Dispatch.erpc_call(Expert.Progress, :begin, [title, opts]) + rescue + _ -> {:ok, @noop_token} end @impl true + def report(@noop_token, _opts), do: :ok + def report(token, opts) when is_token(token) and is_list(opts) do Dispatch.erpc_cast(Expert.Progress, :report, [token, opts]) end @impl true - def complete(token, opts \\ []) when is_token(token) do + def complete(token, opts \\ []) + + def complete(@noop_token, _opts), do: :ok + + def complete(token, opts) when is_token(token) and is_list(opts) do Dispatch.erpc_cast(Expert.Progress, :complete, [token, opts]) end end diff --git a/apps/engine/test/engine/build/state_test.exs b/apps/engine/test/engine/build/state_test.exs index 2095ff5a..b9e834fa 100644 --- a/apps/engine/test/engine/build/state_test.exs +++ b/apps/engine/test/engine/build/state_test.exs @@ -11,13 +11,6 @@ defmodule Engine.Build.StateTest do use Patch setup do - # Mock erpc calls for progress reporting - patch(Engine.Dispatch, :erpc_call, fn Expert.Progress, :begin, [_title, _opts] -> - {:ok, System.unique_integer([:positive])} - end) - - patch(Engine.Dispatch, :erpc_cast, fn Expert.Progress, _function, _args -> true end) - start_supervised!(Engine.Dispatch) start_supervised!(Engine.Api.Proxy) start_supervised!(Build.CaptureServer) diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index 78e4ffd8..6324b9d1 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -1,5 +1,6 @@ defmodule Expert.EngineNode do alias Forge.Project + alias Expert.Progress require Logger defmodule State do @@ -133,7 +134,7 @@ defmodule Expert.EngineNode do alias Forge.Document use GenServer - def start(project, token \\ nil) do + def start(project, token \\ Progress.noop_token()) do start_net_kernel(project) node_name = Project.node_name(project) @@ -141,18 +142,15 @@ defmodule Expert.EngineNode do with {:ok, node_pid} <- EngineSupervisor.start_project_node(project), {:ok, glob_paths} <- glob_paths(project), - :ok <- report_progress(token, "Starting Erlang node..."), + :ok <- Progress.report(token, "Starting Erlang node..."), :ok <- start_node(project, glob_paths), - :ok <- report_progress(token, "Bootstrapping engine..."), + :ok <- Progress.report(token, "Bootstrapping engine..."), :ok <- :rpc.call(node_name, Engine.Bootstrap, :init, bootstrap_args), :ok <- ensure_apps_started(node_name, token) do {:ok, node_name, node_pid} end end - defp report_progress(nil, _message), do: :ok - defp report_progress(token, message), do: Expert.Progress.report(token, message: message) - defp start_net_kernel(%Project{} = project) do manager = Project.manager_node_name(project) Node.start(manager, :longnames) diff --git a/apps/expert/lib/expert/progress.ex b/apps/expert/lib/expert/progress.ex index a68ff90e..2f7b3807 100644 --- a/apps/expert/lib/expert/progress.ex +++ b/apps/expert/lib/expert/progress.ex @@ -11,8 +11,6 @@ defmodule Expert.Progress do require Logger - @noop_token Forge.Progress.noop_token() - # Behaviour implementations @doc """ diff --git a/apps/expert/lib/expert/project/node.ex b/apps/expert/lib/expert/project/node.ex index 3dc05c02..ec4de5dd 100644 --- a/apps/expert/lib/expert/project/node.ex +++ b/apps/expert/lib/expert/project/node.ex @@ -93,7 +93,7 @@ defmodule Expert.Project.Node do # private api - defp start_node(%Project{} = project, token \\ -1) do + defp start_node(%Project{} = project, token \\ Progress.noop_token()) do with {:ok, node, node_pid} <- EngineNode.start(project, token) do Node.monitor(node, true) {:ok, State.new(project, node, node_pid)} diff --git a/apps/expert/test/expert/progress_test.exs b/apps/expert/test/expert/progress_test.exs index 58f67592..9eff891c 100644 --- a/apps/expert/test/expert/progress_test.exs +++ b/apps/expert/test/expert/progress_test.exs @@ -183,16 +183,15 @@ defmodule Expert.ProgressTest do :ok end - test "begin still returns a token" do - assert {:ok, token} = Progress.begin("Building") - assert is_integer(token) + test "begin returns noop token" do + assert {:ok, nil} = Progress.begin("Building") # Should NOT send any requests or notifications refute_received {:request, _} refute_received {:notify, _} end - test "with_progress still executes the work" do + test "with_progress executes the work" do result = Progress.with_progress("Building", fn _token -> {:done, :ok} end) assert result == :ok diff --git a/apps/forge/lib/forge/progress.ex b/apps/forge/lib/forge/progress.ex index 70e40931..b7ce01e8 100644 --- a/apps/forge/lib/forge/progress.ex +++ b/apps/forge/lib/forge/progress.ex @@ -21,9 +21,6 @@ defmodule Forge.Progress do @type token :: integer() | String.t() @type work_result :: {:done, term()} | {:done, term(), String.t()} | {:cancel, term()} - @noop_token -1 - def noop_token, do: @noop_token - @callback begin(title :: String.t(), opts :: keyword()) :: {:ok, token()} | {:error, :rejected} @callback report(token :: token(), opts :: keyword()) :: :ok @callback complete(token :: token(), opts :: keyword()) :: :ok @@ -34,6 +31,10 @@ defmodule Forge.Progress do alias Forge.Progress.Tracker + @noop_token nil + + def noop_token, do: @noop_token + defguardp is_token(token) when is_binary(token) or is_integer(token) @doc """ @@ -86,7 +87,7 @@ defmodule Forge.Progress do defp run_with_progress(title, opts, work_fn) do case begin(title, opts) do {:ok, token} -> execute_work(token, work_fn) - {:error, :rejected} -> elem(work_fn.(Forge.Progress.noop_token()), 1) + {:error, :rejected} -> elem(work_fn.(@noop_token), 1) end end From 03307beb4eeb77019b0922999c08215154f3c068 Mon Sep 17 00:00:00 2001 From: Moosieus Date: Sun, 14 Dec 2025 15:07:32 -0500 Subject: [PATCH 11/17] more explicit patterns --- apps/engine/lib/engine/build/project.ex | 1 + apps/engine/lib/engine/dispatch.ex | 22 ++++++++++--------- apps/engine/lib/engine/progress.ex | 4 +--- apps/expert/lib/expert/progress.ex | 2 +- apps/expert/lib/expert/state.ex | 1 - apps/expert/test/engine/build_test.exs | 1 - apps/expert/test/expert/project/node_test.exs | 4 ++-- 7 files changed, 17 insertions(+), 18 deletions(-) diff --git a/apps/engine/lib/engine/build/project.ex b/apps/engine/lib/engine/build/project.ex index 0b359ea4..27ad7652 100644 --- a/apps/engine/lib/engine/build/project.ex +++ b/apps/engine/lib/engine/build/project.ex @@ -8,6 +8,7 @@ defmodule Engine.Build.Project do def compile(%Project{} = project, initial?) do Logger.info("Building #{Project.display_name(project)}") + Progress.with_progress("Building #{Project.display_name(project)}", fn token -> Build.set_progress_token(token) diff --git a/apps/engine/lib/engine/dispatch.ex b/apps/engine/lib/engine/dispatch.ex index 873bd285..6be1c780 100644 --- a/apps/engine/lib/engine/dispatch.ex +++ b/apps/engine/lib/engine/dispatch.ex @@ -43,6 +43,18 @@ defmodule Engine.Dispatch do :gen_event.notify(__MODULE__, message) end + # bypass via rpc, primarily for progress reporting. + + def erpc_call(module, function, args) do + :erpc.call(manager_node(), module, function, args, 1_000) + end + + def erpc_cast(module, function, args) do + :erpc.cast(manager_node(), module, function, args) + end + + defp manager_node(), do: Engine.get_project() |> Project.manager_node_name() + # GenServer callbacks def start_link(_opts) do @@ -64,14 +76,4 @@ defmodule Engine.Dispatch do end defp name, do: {:local, __MODULE__} - - def erpc_call(module, function, args) do - :erpc.call(manager_node(), module, function, args, 1_000) - end - - def erpc_cast(module, function, args) do - :erpc.cast(manager_node(), module, function, args) - end - - defp manager_node(), do: Engine.get_project() |> Project.manager_node_name() end diff --git a/apps/engine/lib/engine/progress.ex b/apps/engine/lib/engine/progress.ex index eee6734e..9189fe4f 100644 --- a/apps/engine/lib/engine/progress.ex +++ b/apps/engine/lib/engine/progress.ex @@ -10,14 +10,12 @@ defmodule Engine.Progress do @impl true def begin(title, opts \\ []) when is_list(opts) do Dispatch.erpc_call(Expert.Progress, :begin, [title, opts]) - rescue - _ -> {:ok, @noop_token} end @impl true def report(@noop_token, _opts), do: :ok - def report(token, opts) when is_token(token) and is_list(opts) do + def report(token, [_ | _] = opts) when is_token(token) do Dispatch.erpc_cast(Expert.Progress, :report, [token, opts]) end diff --git a/apps/expert/lib/expert/progress.ex b/apps/expert/lib/expert/progress.ex index 2f7b3807..4d3149de 100644 --- a/apps/expert/lib/expert/progress.ex +++ b/apps/expert/lib/expert/progress.ex @@ -69,7 +69,7 @@ defmodule Expert.Progress do @impl Forge.Progress def report(@noop_token, _opts), do: :ok - def report(token, opts) when is_token(token) do + def report(token, [_ | _] = opts) when is_token(token) do notify(token, progress_report(opts)) :ok end diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index 30f07785..2ba810b4 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -50,7 +50,6 @@ defmodule Expert.State do config = Configuration.new(event.root_uri, event.capabilities, client_name) response = initialize_result() - new_state = %__MODULE__{state | configuration: config, initialized?: true} {:ok, response, new_state} diff --git a/apps/expert/test/engine/build_test.exs b/apps/expert/test/engine/build_test.exs index 35180c64..8e87c8a0 100644 --- a/apps/expert/test/engine/build_test.exs +++ b/apps/expert/test/engine/build_test.exs @@ -83,7 +83,6 @@ defmodule Engine.BuildTest do {:ok, project} = with_project(:project_metadata) EngineApi.schedule_compile(project, true) - assert_receive module_updated(name: ProjectMetadata, functions: functions) assert {:zero_arity, 0} in functions diff --git a/apps/expert/test/expert/project/node_test.exs b/apps/expert/test/expert/project/node_test.exs index 1a3f4bc2..1dd30372 100644 --- a/apps/expert/test/expert/project/node_test.exs +++ b/apps/expert/test/expert/project/node_test.exs @@ -35,7 +35,7 @@ defmodule Expert.Project.NodeTest do old_pid = node_pid(project) :ok = EngineApi.stop(project) - assert_eventually(Node.ping(node_name) == :pong, 1000) + assert_eventually Node.ping(node_name) == :pong, 1000 new_pid = node_pid(project) assert is_pid(new_pid) @@ -48,7 +48,7 @@ defmodule Expert.Project.NodeTest do assert is_pid(supervisor_pid) Process.exit(supervisor_pid, :kill) - assert_eventually(Node.ping(node_name) == :pong, 750) + assert_eventually Node.ping(node_name) == :pong, 750 end defp node_pid(project) do From 4cbf779676d46efc23d18bc29f76d8619d99966e Mon Sep 17 00:00:00 2001 From: Moosieus Date: Mon, 15 Dec 2025 21:32:19 -0500 Subject: [PATCH 12/17] final cleanup: * Store the manager node name on startup in the engine node. This fixes up some tests that were faltering due to the introduced rpc calls. This is more stable anyway, imo. * give project compilation more generous timeouts in testing (5s instead of 100ms) --- apps/engine/lib/engine.ex | 8 ++++++++ apps/engine/lib/engine/bootstrap.ex | 3 ++- apps/engine/lib/engine/dispatch.ex | 7 ++----- apps/engine/lib/engine/progress.ex | 2 ++ apps/expert/lib/expert/engine_node.ex | 4 ++-- apps/expert/test/engine/build_test.exs | 9 +++++---- 6 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/engine/lib/engine.ex b/apps/engine/lib/engine.ex index aaf6e773..643e636d 100644 --- a/apps/engine/lib/engine.ex +++ b/apps/engine/lib/engine.ex @@ -114,4 +114,12 @@ defmodule Engine do def set_project(%Project{} = project) do :persistent_term.put({__MODULE__, :project}, project) end + + def get_manager_node do + :persistent_term.get({__MODULE__, :manager_node}, nil) + end + + def set_manager_node(node) when is_atom(node) do + :persistent_term.put({__MODULE__, :manager_node}, node) + end end diff --git a/apps/engine/lib/engine/bootstrap.ex b/apps/engine/lib/engine/bootstrap.ex index 89dbe3c4..25da8364 100644 --- a/apps/engine/lib/engine/bootstrap.ex +++ b/apps/engine/lib/engine/bootstrap.ex @@ -11,7 +11,7 @@ defmodule Engine.Bootstrap do require Logger - def init(%Project{} = project, document_store_entropy, app_configs) do + def init(%Project{} = project, document_store_entropy, app_configs, manager_node) do Forge.Document.Store.set_entropy(document_store_entropy) Application.put_all_env(app_configs) @@ -26,6 +26,7 @@ defmodule Engine.Bootstrap do {:ok, _} <- Application.ensure_all_started(:logger) do project = maybe_load_mix_exs(project) Engine.set_project(project) + Engine.set_manager_node(manager_node) Mix.env(:test) ExUnit.start() start_logger(project) diff --git a/apps/engine/lib/engine/dispatch.ex b/apps/engine/lib/engine/dispatch.ex index 6be1c780..0012ce5e 100644 --- a/apps/engine/lib/engine/dispatch.ex +++ b/apps/engine/lib/engine/dispatch.ex @@ -9,7 +9,6 @@ defmodule Engine.Dispatch do alias Engine.Dispatch.Handlers alias Engine.Dispatch.PubSub - alias Forge.Project @handlers [PubSub, Handlers.Indexing] @@ -46,15 +45,13 @@ defmodule Engine.Dispatch do # bypass via rpc, primarily for progress reporting. def erpc_call(module, function, args) do - :erpc.call(manager_node(), module, function, args, 1_000) + :erpc.call(Engine.get_manager_node(), module, function, args, 1_000) end def erpc_cast(module, function, args) do - :erpc.cast(manager_node(), module, function, args) + :erpc.cast(Engine.get_manager_node(), module, function, args) end - defp manager_node(), do: Engine.get_project() |> Project.manager_node_name() - # GenServer callbacks def start_link(_opts) do diff --git a/apps/engine/lib/engine/progress.ex b/apps/engine/lib/engine/progress.ex index 9189fe4f..98a5640f 100644 --- a/apps/engine/lib/engine/progress.ex +++ b/apps/engine/lib/engine/progress.ex @@ -17,6 +17,7 @@ defmodule Engine.Progress do def report(token, [_ | _] = opts) when is_token(token) do Dispatch.erpc_cast(Expert.Progress, :report, [token, opts]) + :ok end @impl true @@ -26,5 +27,6 @@ defmodule Engine.Progress do def complete(token, opts) when is_token(token) and is_list(opts) do Dispatch.erpc_cast(Expert.Progress, :complete, [token, opts]) + :ok end end diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index 5e3a3eca..c1787a84 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -165,10 +165,10 @@ defmodule Expert.EngineNode do use GenServer def start(project, token \\ Progress.noop_token()) do - start_net_kernel(project) + start_net_kernel(project) node_name = Project.node_name(project) - bootstrap_args = [project, Document.Store.entropy(), all_app_configs()] + bootstrap_args = [project, Document.Store.entropy(), all_app_configs(), Node.self()] with {:ok, node_pid} <- EngineSupervisor.start_project_node(project), {:ok, glob_paths} <- glob_paths(project), diff --git a/apps/expert/test/engine/build_test.exs b/apps/expert/test/engine/build_test.exs index 8e87c8a0..dc010698 100644 --- a/apps/expert/test/engine/build_test.exs +++ b/apps/expert/test/engine/build_test.exs @@ -12,6 +12,7 @@ defmodule Engine.BuildTest do import Messages import Forge.Test.Fixtures import Forge.Test.DiagnosticSupport + use ExUnit.Case use Patch @@ -76,7 +77,7 @@ defmodule Engine.BuildTest do {:ok, project} = with_project(:project_metadata) EngineApi.schedule_compile(project, true) - assert_receive project_compiled(status: :success) + assert_receive project_compiled(status: :success), :timer.seconds(5) end test "receives metadata about the defined modules" do @@ -118,7 +119,7 @@ defmodule Engine.BuildTest do {:ok, project} = with_project(:compilation_errors) EngineApi.schedule_compile(project, true) - assert_receive project_compiled(status: :error) + assert_receive project_compiled(status: :error), :timer.seconds(5) assert_receive project_diagnostics(diagnostics: [%Diagnostic.Result{}]) end end @@ -143,7 +144,7 @@ defmodule Engine.BuildTest do test "stuff when #{inspect(@feature_condition)}", %{project: project} do EngineApi.schedule_compile(project, true) - assert_receive project_compiled(status: :error) + assert_receive project_compiled(status: :error), :timer.seconds(5) assert_receive project_diagnostics(diagnostics: [%Diagnostic.Result{} = diagnostic]) assert diagnostic.uri @@ -159,7 +160,7 @@ defmodule Engine.BuildTest do {:ok, project} = with_project(:compilation_warnings) EngineApi.schedule_compile(project, true) - assert_receive project_compiled(status: :success) + assert_receive project_compiled(status: :success), :timer.seconds(5) assert_receive project_diagnostics(diagnostics: diagnostics) assert [%Diagnostic.Result{}, %Diagnostic.Result{}] = diagnostics From d70e26677a057f9951a02b38f1311a220d303eb0 Mon Sep 17 00:00:00 2001 From: Moosieus Date: Mon, 15 Dec 2025 21:47:54 -0500 Subject: [PATCH 13/17] quickfix messages --- apps/expert/lib/expert/engine_node.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index c1787a84..6aab86cf 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -172,9 +172,9 @@ defmodule Expert.EngineNode do with {:ok, node_pid} <- EngineSupervisor.start_project_node(project), {:ok, glob_paths} <- glob_paths(project), - :ok <- Progress.report(token, "Starting Erlang node..."), + :ok <- Progress.report(token, message: "Starting Erlang node..."), :ok <- start_node(project, glob_paths), - :ok <- Progress.report(token, "Bootstrapping engine..."), + :ok <- Progress.report(token, message: "Bootstrapping engine..."), :ok <- :rpc.call(node_name, Engine.Bootstrap, :init, bootstrap_args), :ok <- ensure_apps_started(node_name, token) do {:ok, node_name, node_pid} From 0c6eda312f53d7ef8edb7771e930a802177e4c2a Mon Sep 17 00:00:00 2001 From: Moosieus Date: Mon, 15 Dec 2025 23:14:18 -0500 Subject: [PATCH 14/17] fix credo warnings + non-mix-at-root-projects --- apps/engine/lib/engine/build.ex | 2 +- apps/engine/lib/engine/build/project.ex | 74 ++++++++-------- apps/engine/lib/engine/build/state.ex | 2 - apps/engine/lib/engine/compilation/tracer.ex | 2 +- apps/engine/lib/engine/search/indexer.ex | 91 +++++++++++--------- apps/expert/lib/expert/engine_node.ex | 5 +- apps/expert/lib/expert/project/node.ex | 12 +-- apps/forge/lib/forge/progress.ex | 2 +- 8 files changed, 98 insertions(+), 92 deletions(-) diff --git a/apps/engine/lib/engine/build.ex b/apps/engine/lib/engine/build.ex index 3ab4ac81..03bb6c5d 100644 --- a/apps/engine/lib/engine/build.ex +++ b/apps/engine/lib/engine/build.ex @@ -1,7 +1,7 @@ defmodule Engine.Build do - alias Forge.{Document, Project} alias Engine.Build.Document.Compilers.HEEx alias Engine.Build.State + alias Forge.{Document, Project} require Logger use GenServer diff --git a/apps/engine/lib/engine/build/project.ex b/apps/engine/lib/engine/build/project.ex index 27ad7652..50384b6a 100644 --- a/apps/engine/lib/engine/build/project.ex +++ b/apps/engine/lib/engine/build/project.ex @@ -7,52 +7,52 @@ defmodule Engine.Build.Project do require Logger def compile(%Project{} = project, initial?) do - Logger.info("Building #{Project.display_name(project)}") + Engine.Mix.in_project(fn _ -> + Logger.info("Building #{Project.display_name(project)}") - Progress.with_progress("Building #{Project.display_name(project)}", fn token -> - Build.set_progress_token(token) + Progress.with_progress("Building #{Project.display_name(project)}", fn token -> + Build.set_progress_token(token) - try do - {:done, do_compile(project, initial?, token)} - after - Build.clear_progress_token() - end + try do + {:done, do_compile(project, initial?, token)} + after + Build.clear_progress_token() + end + end) end) end defp do_compile(project, initial?, token) do - Engine.Mix.in_project(fn _ -> + Mix.Task.clear() + + if initial?, do: prepare_for_project_build(token) + + compile_fun = fn -> Mix.Task.clear() + Progress.report(token, message: "Compiling #{Project.display_name(project)}") + result = compile_in_isolation() + Mix.Task.run(:loadpaths) + result + end - if initial?, do: prepare_for_project_build(token) + case compile_fun.() do + {:error, diagnostics} -> + diagnostics = + diagnostics + |> List.wrap() + |> Build.Error.refine_diagnostics() - compile_fun = fn -> - Mix.Task.clear() - Progress.report(token, message: "Compiling #{Project.display_name(project)}") - result = compile_in_isolation() - Mix.Task.run(:loadpaths) - result - end - - case compile_fun.() do - {:error, diagnostics} -> - diagnostics = - diagnostics - |> List.wrap() - |> Build.Error.refine_diagnostics() - - {:error, diagnostics} - - {status, diagnostics} when status in [:ok, :noop] -> - Logger.info( - "Compile completed with status #{status} " <> - "Produced #{length(diagnostics)} diagnostics " <> - inspect(diagnostics) - ) - - Build.Error.refine_diagnostics(diagnostics) - end - end) + {:error, diagnostics} + + {status, diagnostics} when status in [:ok, :noop] -> + Logger.info( + "Compile completed with status #{status} " <> + "Produced #{length(diagnostics)} diagnostics " <> + inspect(diagnostics) + ) + + Build.Error.refine_diagnostics(diagnostics) + end end defp compile_in_isolation do diff --git a/apps/engine/lib/engine/build/state.ex b/apps/engine/lib/engine/build/state.ex index a26df36a..32768134 100644 --- a/apps/engine/lib/engine/build/state.ex +++ b/apps/engine/lib/engine/build/state.ex @@ -80,8 +80,6 @@ defmodule Engine.Build.State do state = increment_build_number(state) project = state.project - Logger.info("Compiling project #{Project.display_name(project)}") - Build.with_lock(fn -> compile_requested_message = project_compile_requested(project: project, build_number: state.build_number) diff --git a/apps/engine/lib/engine/compilation/tracer.ex b/apps/engine/lib/engine/compilation/tracer.ex index c2a18fe2..0b3e5ae4 100644 --- a/apps/engine/lib/engine/compilation/tracer.ex +++ b/apps/engine/lib/engine/compilation/tracer.ex @@ -1,7 +1,7 @@ defmodule Engine.Compilation.Tracer do + alias Engine.Build alias Engine.Module.Loader alias Engine.Progress - alias Engine.Build import Forge.EngineApi.Messages diff --git a/apps/engine/lib/engine/search/indexer.ex b/apps/engine/lib/engine/search/indexer.ex index 957fbea7..e6e2b734 100644 --- a/apps/engine/lib/engine/search/indexer.ex +++ b/apps/engine/lib/engine/search/indexer.ex @@ -97,57 +97,62 @@ defmodule Engine.Search.Indexer do |> path_to_sizes() |> Enum.shuffle() - path_to_size_map = Map.new(paths_to_sizes) - total_bytes = paths_to_sizes |> Enum.map(&elem(&1, 1)) |> Enum.sum() if total_bytes > 0 do - Progress.with_tracked_progress("Indexing source code", total_bytes, fn report -> - initial_state = {0, []} - - chunk_fn = fn {path, file_size}, {block_size, paths} -> - new_block_size = file_size + block_size - new_paths = [path | paths] - - if new_block_size >= @bytes_per_block do - {:cont, new_paths, initial_state} - else - {:cont, {new_block_size, new_paths}} - end - end - - after_fn = fn - {_, []} -> {:cont, []} - {_, paths} -> {:cont, paths, []} - end - - result = - paths_to_sizes - |> Stream.chunk_while(initial_state, chunk_fn, after_fn) - |> Task.async_stream( - fn chunk -> - block_bytes = chunk |> Enum.map(&Map.get(path_to_size_map, &1)) |> Enum.sum() - result = Enum.flat_map(chunk, processor) - - report.(message: "Indexing", add: block_bytes) - - result - end, - timeout: timeout - ) - |> Stream.flat_map(fn - {:ok, entries} -> entries - _ -> [] - end) - |> Enum.to_list() - - {:done, result} - end) + process_chunks(paths_to_sizes, total_bytes, processor, timeout) else [] end end + defp process_chunks(paths_to_sizes, total_bytes, processor, timeout) do + path_to_size_map = Map.new(paths_to_sizes) + + Progress.with_tracked_progress("Indexing source code", total_bytes, fn report -> + result = do_process_chunks(paths_to_sizes, path_to_size_map, processor, timeout, report) + {:done, result} + end) + end + + defp do_process_chunks(paths_to_sizes, path_to_size_map, processor, timeout, report) do + initial_state = {0, []} + + chunk_fn = fn {path, file_size}, {block_size, paths} -> + new_block_size = file_size + block_size + new_paths = [path | paths] + + if new_block_size >= @bytes_per_block do + {:cont, new_paths, initial_state} + else + {:cont, {new_block_size, new_paths}} + end + end + + after_fn = fn + {_, []} -> {:cont, []} + {_, paths} -> {:cont, paths, []} + end + + paths_to_sizes + |> Stream.chunk_while(initial_state, chunk_fn, after_fn) + |> Task.async_stream( + fn chunk -> + block_bytes = chunk |> Enum.map(&Map.get(path_to_size_map, &1)) |> Enum.sum() + + report.(message: "Indexing", add: block_bytes) + + Enum.flat_map(chunk, processor) + end, + timeout: timeout + ) + |> Stream.flat_map(fn + {:ok, entries} -> entries + _ -> [] + end) + |> Enum.to_list() + end + defp path_to_sizes(paths) do Enum.reduce(paths, [], fn file_path, acc -> case File.stat(file_path) do diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index 6aab86cf..f6fd38bc 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -1,6 +1,7 @@ defmodule Expert.EngineNode do - alias Forge.Project alias Expert.Progress + alias Forge.Project + require Logger defmodule State do @@ -165,7 +166,7 @@ defmodule Expert.EngineNode do use GenServer def start(project, token \\ Progress.noop_token()) do - start_net_kernel(project) + start_net_kernel(project) node_name = Project.node_name(project) bootstrap_args = [project, Document.Store.entropy(), all_app_configs(), Node.self()] diff --git a/apps/expert/lib/expert/project/node.ex b/apps/expert/lib/expert/project/node.ex index ec4de5dd..d4471c09 100644 --- a/apps/expert/lib/expert/project/node.ex +++ b/apps/expert/lib/expert/project/node.ex @@ -50,12 +50,14 @@ defmodule Expert.Project.Node do @impl GenServer def init(%Project{} = project) do - Progress.with_progress("Starting project node", fn token -> - result = start_node(project, token) + result = + Progress.with_progress("Starting project node", fn token -> + result = start_node(project, token) - {:done, result, "Project node started"} - end) - |> case do + {:done, result, "Project node started"} + end) + + case result do {:ok, state} -> {:ok, state, {:continue, :trigger_build}} error -> {:stop, error} end diff --git a/apps/forge/lib/forge/progress.ex b/apps/forge/lib/forge/progress.ex index b7ce01e8..9db1b2de 100644 --- a/apps/forge/lib/forge/progress.ex +++ b/apps/forge/lib/forge/progress.ex @@ -93,7 +93,7 @@ defmodule Forge.Progress do defp execute_work(token, work_fn) do try do - work_fn.(token) |> complete_with(token) + token |> work_fn.() |> complete_with(token) rescue e -> complete(token, message: "Error: #{Exception.message(e)}") From cb8249b461b18c82445464ebfcdf649d4612a946 Mon Sep 17 00:00:00 2001 From: Moosieus Date: Tue, 16 Dec 2025 17:17:54 -0500 Subject: [PATCH 15/17] use log helpers --- apps/expert/lib/expert/engine_node.ex | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index f6fd38bc..44f8d4bc 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -223,7 +223,7 @@ defmodule Expert.EngineNode do case Expert.Port.elixir_executable(project) do {:ok, elixir, env} -> - GenLSP.info(lsp, "Found elixir for #{project_name} at #{elixir}") + Expert.log_info(lsp, "Finding or building engine at #{elixir}") expert_priv = :code.priv_dir(:expert) packaged_engine_source = Path.join([expert_priv, "engine_source", "apps", "engine"]) @@ -252,8 +252,7 @@ defmodule Expert.EngineNode do launcher = Expert.Port.path() - Logger.info("Finding or building engine for project #{project_name}") - GenLSP.info(lsp, "Finding or building engine for project #{project_name}") + Expert.log_info(lsp, "Finding or building engine") Expert.Progress.with_progress("Building engine for #{project_name}", fn _token -> result = From b7a8189cfb45ba37fa7e092de528c3ea8c4705a7 Mon Sep 17 00:00:00 2001 From: Moosieus Date: Tue, 16 Dec 2025 18:32:24 -0500 Subject: [PATCH 16/17] fix gaffe in log --- apps/expert/lib/expert/engine_node.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index 44f8d4bc..47fefd71 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -223,7 +223,7 @@ defmodule Expert.EngineNode do case Expert.Port.elixir_executable(project) do {:ok, elixir, env} -> - Expert.log_info(lsp, "Finding or building engine at #{elixir}") + Expert.log_info(lsp, "Found elixir executable for project at #{elixir}") expert_priv = :code.priv_dir(:expert) packaged_engine_source = Path.join([expert_priv, "engine_source", "apps", "engine"]) From ed03d801da1f7d772e710b11257fca91fd00210e Mon Sep 17 00:00:00 2001 From: Moosieus Date: Sat, 20 Dec 2025 16:05:24 -0500 Subject: [PATCH 17/17] fix ref --- apps/expert/lib/expert/engine_node.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index 37d13dcd..73d93faa 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -285,7 +285,7 @@ defmodule Expert.EngineNode do project_name = Project.name(project) - Expert.Project.with_progress("Building engine for #{project_name}", fn -> + Expert.Progress.with_progress("Building engine for #{project_name}", fn _token -> result = fn -> Process.flag(:trap_exit, true)