Skip to content

Commit 695247d

Browse files
hyperpolymathclaude
andcommitted
feat(orchestrator-lsp-mcp): Steps 1-3 — Elixir adapter, Zig FFI, ReScript VSCode extension
Step 1: Elixir GenLSP adapter (adapter/) - Application supervisor with GenLSP LSP server - Domain planner routes by file extension and workspace path - Fan-out executor uses Task.async_stream with 5 s timeout - Handlers: completion (merge+tag), hover (domain headings), diagnostics (prefix) - Stack parser reads .machine_readable/integrations/*.a2ml; 12-domain port fallback - LSP client pool registry (domain → pid) - VeriSimDB client: session record/close via :httpc, graceful :unavailable - 41 ExUnit tests across planner, stack_parser, completion, hover Step 2: Zig FFI (ffi/zig/) - Full ADR-0006 five-symbol ABI: init/deinit/name/version/invoke - Session table: MAX_SESSIONS=8 with workspace_root and 12-domain bitset - lsp_orchestrate_start/stop managed in Zig (no port needed) - lsp_orchestrate_request/status delegate to Elixir adapter via Erlang port protocol (4-byte BE framing, single mutex for full round-trip) - Lazy port spawn via page_allocator; graceful offline error if adapter absent - build.zig: imports cartridge_shim from ../../../../ffi/zig/src/ - 12 unit tests covering all tools, error paths, and limit enforcement Step 3: ReScript VSCode extension (panels/) - Extension.res: activate/deactivate, resolves adapter dir from config or env - VscodeApi.res: external bindings for window, workspace, output channel - LanguageClient.res: external bindings for vscode-languageclient/node - package.json: VSCode manifest + vscode-languageclient dep (platform exception) - rescript.json: CommonJS output to lib/js/src/Extension.js - All 12 domains accepted (routing inside adapter); trace.server configurable Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 039908a commit 695247d

23 files changed

Lines changed: 2313 additions & 0 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
#
4+
# Merges completion item lists from multiple domain LSP servers.
5+
#
6+
# Strategy:
7+
# 1. Flatten all items from all domains into a single list.
8+
# 2. Prefix the item's "detail" field with "[<domain>]" so the user can
9+
# see which domain contributed the suggestion.
10+
# 3. Deduplicate by "label" — first occurrence wins (domain order from
11+
# Planner.route/2 acts as implicit priority).
12+
13+
defmodule OrchestratorLspMcp.LSP.Handlers.Completion do
14+
@moduledoc """
15+
Merges completion item lists from multiple domain LSP servers.
16+
17+
Each domain returns a (possibly nil) list of LSP CompletionItem maps.
18+
This module tags each item's `detail` field with the originating domain
19+
and deduplicates by `label`, keeping the first occurrence.
20+
"""
21+
22+
@doc """
23+
Merge a keyword list of `{domain, items}` pairs into a single
24+
deduplicated completion list.
25+
26+
## Examples
27+
28+
iex> Completion.merge([{"k8s", [%{"label" => "Pod"}]}, {"db", nil}])
29+
[%{"label" => "Pod", "detail" => "[k8s] "}]
30+
"""
31+
@spec merge([{String.t(), list(map()) | nil}]) :: list(map())
32+
def merge(results) do
33+
results
34+
|> Enum.flat_map(fn {domain, items} ->
35+
# Treat nil (domain unavailable or no results) as an empty list.
36+
Enum.map(items || [], fn item ->
37+
detail = Map.get(item, "detail", "")
38+
Map.put(item, "detail", "[#{domain}] #{detail}")
39+
end)
40+
end)
41+
|> Enum.uniq_by(& &1["label"])
42+
end
43+
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
#
4+
# Merges diagnostic lists from multiple domain LSP servers for a single
5+
# document URI, to be published back to the editor via
6+
# textDocument/publishDiagnostics.
7+
#
8+
# Strategy:
9+
# - Flatten all diagnostics from all domains.
10+
# - Prefix each diagnostic's "source" field with "<domain>:" so the
11+
# editor's Problems panel shows which server raised each issue.
12+
# - Preserve the full LSP Diagnostic object otherwise (range, severity,
13+
# message, code, etc.) so editors can render it correctly.
14+
15+
defmodule OrchestratorLspMcp.LSP.Handlers.Diagnostics do
16+
@moduledoc """
17+
Merges diagnostic lists from multiple domain LSP servers.
18+
19+
Each domain returns a (possibly nil) list of LSP Diagnostic maps for a
20+
given document URI. This module tags each diagnostic's `source` field
21+
with the originating domain name and returns a single
22+
publishDiagnostics payload map.
23+
"""
24+
25+
@doc """
26+
Merge diagnostics from multiple domains for `uri`.
27+
28+
Returns a map ready to pass to GenLSP as a publishDiagnostics notification:
29+
30+
%{"uri" => uri, "diagnostics" => [...]}
31+
"""
32+
@spec merge(String.t(), [{String.t(), list(map()) | nil}]) :: map()
33+
def merge(uri, results) do
34+
diagnostics =
35+
Enum.flat_map(results, fn {domain, diags} ->
36+
Enum.map(diags || [], fn d ->
37+
# Prepend "<domain>:" to existing source (may be empty string).
38+
existing_source = Map.get(d, "source", "")
39+
Map.put(d, "source", "#{domain}:#{existing_source}")
40+
end)
41+
end)
42+
43+
%{"uri" => uri, "diagnostics" => diagnostics}
44+
end
45+
end
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
#
4+
# Merges hover responses from multiple domain LSP servers into a single
5+
# MarkupContent block presented to the editor.
6+
#
7+
# Strategy:
8+
# - Filter out nil responses (domain had nothing to say).
9+
# - Extract the "value" string from each domain's "contents" map.
10+
# - Emit one markdown section per domain headed by "### <domain>".
11+
# - Separate sections with a horizontal rule ("---").
12+
# - Return nil when no domain produced a result (editor hides the hover).
13+
14+
defmodule OrchestratorLspMcp.LSP.Handlers.Hover do
15+
@moduledoc """
16+
Merges hover responses from multiple domain LSP servers.
17+
18+
Each domain may return nil (no hover for this position) or a map
19+
containing a `"contents"` key (MarkupContent or plain string).
20+
Results are concatenated into a single markdown document with a
21+
per-domain heading, or nil when all domains return nil.
22+
"""
23+
24+
@doc """
25+
Merge a keyword list of `{domain, hover_result}` pairs.
26+
27+
Returns a merged LSP Hover map (`%{"contents" => %{"kind" => "markdown", ...}}`)
28+
or `nil` if every domain returned nil.
29+
"""
30+
@spec merge([{String.t(), map() | nil}]) :: map() | nil
31+
def merge(results) do
32+
contents =
33+
results
34+
|> Enum.reject(fn {_domain, r} -> is_nil(r) end)
35+
|> Enum.map(fn {domain, r} ->
36+
# Support both MarkupContent (%{"contents" => %{"value" => ...}})
37+
# and plain string contents (%{"contents" => "..."}).
38+
body =
39+
get_in(r, ["contents", "value"]) ||
40+
get_in(r, ["contents"]) ||
41+
""
42+
43+
"### #{domain}\n\n#{body}"
44+
end)
45+
46+
case contents do
47+
[] ->
48+
nil
49+
50+
sections ->
51+
%{
52+
"contents" => %{
53+
"kind" => "markdown",
54+
"value" => Enum.join(sections, "\n\n---\n\n")
55+
}
56+
}
57+
end
58+
end
59+
end
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
#
4+
# GenLSP server entry-point for the orchestrator cartridge.
5+
#
6+
# Responsibilities:
7+
# - Negotiate LSP capabilities with the editor client (initialize handshake)
8+
# - Delegate textDocument/completion and textDocument/hover requests to
9+
# Executor.fan_out/3, which fans them out to the active domain servers in
10+
# parallel and merges the results.
11+
# - Broadcast open/change/close notifications to all active domain servers.
12+
# - Relay publishDiagnostics push-notifications from domain servers back to
13+
# the editor (wired via Executor, not handled directly here).
14+
#
15+
# State (lsp.assigns):
16+
# :domains – list of %{domain: string, port: integer} maps from Planner
17+
# :session_id – opaque reference used for VeriSimDB session tracking
18+
19+
defmodule OrchestratorLspMcp.LSP.Server do
20+
use GenLSP
21+
22+
alias OrchestratorLspMcp.Orchestrator.{Planner, Executor}
23+
alias OrchestratorLspMcp.VeriSimDB.Client, as: DB
24+
25+
# ──────────────────────────────────────────────────────────────────────
26+
# Lifecycle
27+
# ──────────────────────────────────────────────────────────────────────
28+
29+
@impl true
30+
def init(lsp, _args) do
31+
{:ok, assign(lsp, domains: [], session_id: nil)}
32+
end
33+
34+
# ──────────────────────────────────────────────────────────────────────
35+
# Request handlers
36+
# ──────────────────────────────────────────────────────────────────────
37+
38+
@impl true
39+
def handle_request(%GenLSP.Requests.Initialize{} = req, lsp) do
40+
root = get_in(req, [:params, :rootUri])
41+
domains = Planner.active_domains(root)
42+
session_id = make_ref()
43+
44+
# Persist session asynchronously — VeriSimDB unavailability must not
45+
# block the initialize handshake.
46+
Task.start(fn -> DB.record_session(session_id, domains, root || "") end)
47+
48+
caps = Planner.merged_capabilities(domains)
49+
50+
{:reply, %{capabilities: caps},
51+
assign(lsp, domains: domains, session_id: session_id)}
52+
end
53+
54+
@impl true
55+
def handle_request(%GenLSP.Requests.TextDocumentCompletion{} = req, lsp) do
56+
result = Executor.fan_out(:completion, req.params, lsp.assigns.domains)
57+
{:reply, result, lsp}
58+
end
59+
60+
@impl true
61+
def handle_request(%GenLSP.Requests.TextDocumentHover{} = req, lsp) do
62+
result = Executor.fan_out(:hover, req.params, lsp.assigns.domains)
63+
{:reply, result, lsp}
64+
end
65+
66+
# ──────────────────────────────────────────────────────────────────────
67+
# Notification handlers
68+
# ──────────────────────────────────────────────────────────────────────
69+
70+
@impl true
71+
def handle_notification(%GenLSP.Notifications.Initialized{}, lsp) do
72+
# Nothing to do beyond acknowledging the handshake is complete.
73+
{:noreply, lsp}
74+
end
75+
76+
@impl true
77+
def handle_notification(%GenLSP.Notifications.TextDocumentDidOpen{} = notif, lsp) do
78+
Executor.broadcast_notification(:did_open, notif.params, lsp.assigns.domains)
79+
{:noreply, lsp}
80+
end
81+
82+
@impl true
83+
def handle_notification(%GenLSP.Notifications.TextDocumentDidChange{} = notif, lsp) do
84+
Executor.broadcast_notification(:did_change, notif.params, lsp.assigns.domains)
85+
{:noreply, lsp}
86+
end
87+
88+
@impl true
89+
def handle_notification(%GenLSP.Notifications.TextDocumentDidClose{} = notif, lsp) do
90+
Executor.broadcast_notification(:did_close, notif.params, lsp.assigns.domains)
91+
92+
# Close VeriSimDB session when the last document is closed (best-effort).
93+
Task.start(fn -> DB.close_session(lsp.assigns.session_id) end)
94+
95+
{:noreply, lsp}
96+
end
97+
98+
# Catch-all: ignore unknown notifications silently.
99+
@impl true
100+
def handle_notification(_, lsp), do: {:noreply, lsp}
101+
end
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
#
4+
# Fans out LSP requests to the selected domain servers in parallel and
5+
# merges their responses via the appropriate Handlers module.
6+
#
7+
# fan_out/3 – parallel request + merge for :completion and :hover
8+
# broadcast_notification/3 – fire-and-forget notification relay to all domains
9+
#
10+
# Failure isolation:
11+
# - Per-domain tasks that time out or crash are silently dropped (domain
12+
# unavailability must never crash the orchestrator).
13+
# - The @timeout is intentionally conservative (5 s) to avoid stacking
14+
# slow domains into a cascade that blocks the editor.
15+
16+
defmodule OrchestratorLspMcp.Orchestrator.Executor do
17+
@moduledoc """
18+
Fans out LSP requests to domain servers in parallel and merges results.
19+
20+
Uses `Task.async_stream/3` for parallel request dispatch with a hard
21+
per-domain timeout. Timed-out or crashed tasks are silently dropped so
22+
that a single offline domain never blocks completion/hover for the rest.
23+
"""
24+
25+
alias OrchestratorLspMcp.Orchestrator.{Planner, LSPClientPool}
26+
alias OrchestratorLspMcp.LSP.Handlers.{Completion, Hover}
27+
28+
# Per-domain request timeout in milliseconds.
29+
@timeout 5_000
30+
31+
# ──────────────────────────────────────────────────────────────────────
32+
# Public API
33+
# ──────────────────────────────────────────────────────────────────────
34+
35+
@doc """
36+
Fan out `method` (:completion | :hover) to the domains selected by
37+
Planner.route/2 and merge the results.
38+
39+
Returns the merged LSP response (list for completion, map/nil for hover).
40+
"""
41+
@spec fan_out(:completion | :hover, map(), [map()]) :: term()
42+
def fan_out(:completion, params, domains) do
43+
uri = get_in(params, ["textDocument", "uri"]) || ""
44+
routed = Planner.route(uri, domains)
45+
results = parallel_request(routed, :completion, params)
46+
Completion.merge(results)
47+
end
48+
49+
def fan_out(:hover, params, domains) do
50+
uri = get_in(params, ["textDocument", "uri"]) || ""
51+
routed = Planner.route(uri, domains)
52+
results = parallel_request(routed, :hover, params)
53+
Hover.merge(results)
54+
end
55+
56+
@doc """
57+
Broadcast a notification to all active domain servers.
58+
59+
Fire-and-forget: errors and timeouts per domain are silently swallowed.
60+
Uses `Task.Supervisor.async_stream_nolink/4` to avoid linking the
61+
broadcast tasks to the calling process.
62+
"""
63+
@spec broadcast_notification(atom(), map(), [map()]) :: :ok
64+
def broadcast_notification(method, params, domains) do
65+
Task.Supervisor.async_stream_nolink(
66+
OrchestratorLspMcp.ExecutionSupervisor,
67+
domains,
68+
fn d -> send_notification(d, method, params) end,
69+
timeout: @timeout,
70+
on_timeout: :kill_task
71+
)
72+
|> Stream.run()
73+
74+
:ok
75+
end
76+
77+
# ──────────────────────────────────────────────────────────────────────
78+
# Private helpers
79+
# ──────────────────────────────────────────────────────────────────────
80+
81+
# Dispatch `method` to each domain in parallel; collect {domain, result} pairs.
82+
# Tasks that exit (timeout or crash) produce no result entry.
83+
defp parallel_request(domains, method, params) do
84+
domains
85+
|> Task.async_stream(
86+
fn d -> {d.domain, send_request(d, method, params)} end,
87+
timeout: @timeout,
88+
on_timeout: :kill_task
89+
)
90+
|> Enum.flat_map(fn
91+
{:ok, result} -> [result]
92+
# Drop timed-out or crashed tasks without propagating the error.
93+
{:exit, _reason} -> []
94+
end)
95+
end
96+
97+
# Send a synchronous LSP request to a single domain client process.
98+
# Returns nil if the domain is not registered in the pool, or on any error.
99+
defp send_request(domain_info, method, params) do
100+
case LSPClientPool.get(domain_info.domain) do
101+
nil ->
102+
nil
103+
104+
pid ->
105+
GenServer.call(pid, {:request, method, params}, @timeout)
106+
end
107+
rescue
108+
# Catch exit/timeout from GenServer.call so callers always get a value.
109+
_ -> nil
110+
end
111+
112+
# Send a fire-and-forget cast to a single domain client process.
113+
defp send_notification(domain_info, method, params) do
114+
case LSPClientPool.get(domain_info.domain) do
115+
nil -> :noop
116+
pid -> GenServer.cast(pid, {:notification, method, params})
117+
end
118+
end
119+
end

0 commit comments

Comments
 (0)