Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 108 additions & 1 deletion lib/monkey_claw/model_registry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ defmodule MonkeyClaw.ModelRegistry do
alias MonkeyClaw.ModelRegistry.EtsHeir
alias MonkeyClaw.ModelRegistry.Provider
alias MonkeyClaw.Repo
alias MonkeyClaw.Vault

@ets_table :monkey_claw_model_registry
@default_interval_ms :timer.hours(24)
Expand Down Expand Up @@ -160,6 +161,26 @@ defmodule MonkeyClaw.ModelRegistry do
GenServer.call(__MODULE__, :refresh_all, :infinity)
end

@doc """
Probe upstream APIs using vault secrets from the given workspace.

Discovers which vault secrets map to known backends via a static
`provider → backend` mapping, then probes each backend synchronously
with the corresponding secret name and workspace ID. On success,
auto-configures `state.backends`, `state.backend_configs`, and
`state.workspace_id` so future tick probes continue without manual
intervention.

Returns `:ok` when at least one backend was probed (individual probe
failures are logged but do not fail the call), or
`{:error, :no_backends_discovered}` when no vault secrets map to a
known backend.
"""
@spec refresh_for_workspace(Ecto.UUID.t()) :: :ok | {:error, :no_backends_discovered}
def refresh_for_workspace(workspace_id) when is_binary(workspace_id) do
GenServer.call(__MODULE__, {:refresh_for_workspace, workspace_id}, :infinity)
end

@doc """
Register a capability token for session-hook writes.

Expand Down Expand Up @@ -314,6 +335,30 @@ defmodule MonkeyClaw.ModelRegistry do
{:reply, :ok, state}
end

def handle_call({:refresh_for_workspace, workspace_id}, _from, %State{} = state) do
case discover_workspace_backends(workspace_id) do
[] ->
{:reply, {:error, :no_backends_discovered}, state}

backend_specs ->
state = bootstrap_workspace_config(backend_specs, workspace_id, state)

state =
Enum.reduce(backend_specs, state, fn {backend, secret_name}, acc ->
{_result, new_state} =
do_synchronous_probe(
backend,
probe_opts_for_workspace(backend, workspace_id, secret_name),
acc
)

new_state
end)

{:reply, :ok, state}
end
end

def handle_call({:configure, opts}, _from, %State{} = state) do
case validate_configure_opts(opts, state) do
:ok ->
Expand Down Expand Up @@ -524,8 +569,10 @@ defmodule MonkeyClaw.ModelRegistry do
# ── Private — Synchronous probe ─────────────────────────────

defp do_synchronous_probe(backend, state) do
{adapter, opts} = probe_opts(backend, state)
do_synchronous_probe(backend, probe_opts(backend, state), state)
end

defp do_synchronous_probe(backend, {adapter, opts}, state) do
task =
Task.Supervisor.async_nolink(MonkeyClaw.TaskSupervisor, fn ->
adapter.list_models(opts)
Expand Down Expand Up @@ -561,6 +608,66 @@ defmodule MonkeyClaw.ModelRegistry do
end
end

# ── Private — Workspace-aware probing ──────────────────────

# Static reverse mapping from vault secret provider to ModelRegistry
# backend name. Mirrors the forward mapping in BeamAgent.backend_to_provider/1.
@provider_to_backend %{
"anthropic" => "claude",
"openai" => "codex",
"google" => "gemini"
Comment thread
beardedeagle marked this conversation as resolved.
Outdated
}
Comment thread
beardedeagle marked this conversation as resolved.

# Discovers which vault secrets in a workspace map to known backends.
# Returns [{backend, secret_name}] — one entry per discoverable backend.
@spec discover_workspace_backends(Ecto.UUID.t()) :: [{String.t(), String.t()}]
defp discover_workspace_backends(workspace_id) do
workspace_id
|> Vault.list_secrets()
|> Enum.flat_map(fn secret ->
case Map.get(@provider_to_backend, secret.provider) do
nil -> []
backend -> [{backend, secret.name}]
end
end)
|> Enum.uniq_by(fn {backend, _} -> backend end)
end

# Builds {adapter, opts} for a workspace-derived probe — bypasses
# state.backend_configs and injects workspace_id + secret_name directly.
defp probe_opts_for_workspace(backend, workspace_id, secret_name) do
{MonkeyClaw.AgentBridge.Backend.BeamAgent,
%{backend: backend, workspace_id: workspace_id, secret_name: secret_name}}
end

# Merges discovered backends into GenServer state so future tick
# probes and refresh_all/0 calls work without reconfiguration.
defp bootstrap_workspace_config(backend_specs, workspace_id, state) do
new_backends =
backend_specs
|> Enum.map(fn {backend, _} -> backend end)
|> Enum.concat(state.backends)
|> Enum.uniq()

new_configs =
Enum.reduce(backend_specs, state.backend_configs, fn {backend, secret_name}, acc ->
Map.put_new(acc, backend, %{secret_name: secret_name})
Comment thread
beardedeagle marked this conversation as resolved.
Outdated
end)

new_last_probe =
Enum.reduce(new_backends, state.last_probe_at, fn backend, acc ->
Map.put_new(acc, backend, nil)
end)

%{
state
| backends: new_backends,
backend_configs: new_configs,
workspace_id: workspace_id,
last_probe_at: new_last_probe
}
end

# ── Private — Probe result handling ─────────────────────────

defp handle_probe_result(backend, {:ok, []}, state) do
Expand Down
23 changes: 14 additions & 9 deletions lib/monkey_claw_web/live/vault_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ defmodule MonkeyClawWeb.VaultLive do
* **Tokens tab** — List and delete OAuth tokens with
active/expired status indicators.
* **Models tab** — Browse cached models grouped by provider
with per-model backend tags, trigger on-demand refresh.
with per-model backend tags. Refresh probes upstream APIs
using vault secrets from the current workspace via
`ModelRegistry.refresh_for_workspace/1`, which also
auto-configures the registry for future tick probes.
Comment thread
beardedeagle marked this conversation as resolved.
Outdated

## Security Invariant

Expand Down Expand Up @@ -207,7 +210,7 @@ defmodule MonkeyClawWeb.VaultLive do
# ── Model Events ───────────────────────────────────────────

def handle_event("refresh_models", _params, socket) do
case spawn_refresh_task() do
case spawn_refresh_task(socket.assigns.workspace_id) do
{:ok, _child} ->
{:noreply, assign(socket, :refreshing_models, true)}

Expand Down Expand Up @@ -618,8 +621,8 @@ defmodule MonkeyClawWeb.VaultLive do
%{}
end

defp safe_refresh_models do
ModelRegistry.refresh_all()
defp safe_refresh_models(workspace_id) do
ModelRegistry.refresh_for_workspace(workspace_id)
rescue
e -> {:error, Exception.message(e)}
end
Expand All @@ -628,21 +631,23 @@ defmodule MonkeyClawWeb.VaultLive do
# success or {:error, reason} if the registry or supervisor is
# unavailable. Split into two functions to satisfy Credo's max
# nesting depth of 2.
@spec spawn_refresh_task() :: {:ok, pid()} | {:error, String.t()}
defp spawn_refresh_task do
@spec spawn_refresh_task(Ecto.UUID.t() | nil) :: {:ok, pid()} | {:error, String.t()}
defp spawn_refresh_task(nil), do: {:error, "No workspace available"}

defp spawn_refresh_task(workspace_id) do
case Process.whereis(ModelRegistry) do
nil -> {:error, "Model registry is not running"}
_pid -> start_refresh_child()
_pid -> start_refresh_child(workspace_id)
end
end

defp start_refresh_child do
defp start_refresh_child(workspace_id) do
lv = self()

case Task.Supervisor.start_child(MonkeyClaw.TaskSupervisor, fn ->
result =
try do
safe_refresh_models()
safe_refresh_models(workspace_id)
catch
kind, reason -> {:error, "#{kind}: #{inspect(reason)}"}
end
Expand Down
115 changes: 115 additions & 0 deletions test/monkey_claw/model_registry/workspace_refresh_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
defmodule MonkeyClaw.ModelRegistry.WorkspaceRefreshTest do
@moduledoc """
Integration tests for ModelRegistry.refresh_for_workspace/1.

Verifies that the workspace-aware refresh path discovers vault
secrets, maps providers to backends, probes upstream (which fails
deterministically via unreachable localhost), and auto-configures
the registry state for future tick probes.

Runs serially because ModelRegistry is a named singleton.
"""

use MonkeyClaw.DataCase, async: false

import MonkeyClaw.Factory

alias MonkeyClaw.ModelRegistry

setup do
original = Application.get_env(:monkey_claw, MonkeyClaw.ModelRegistry.Baseline)
Application.put_env(:monkey_claw, MonkeyClaw.ModelRegistry.Baseline, entries: [])

on_exit(fn ->
if original,
do: Application.put_env(:monkey_claw, MonkeyClaw.ModelRegistry.Baseline, original),
else: Application.delete_env(:monkey_claw, MonkeyClaw.ModelRegistry.Baseline)
end)

start_supervised!(
{MonkeyClaw.ModelRegistry.EtsHeir, [table_name: :monkey_claw_model_registry]}
)

start_supervised!({ModelRegistry, [backends: [], default_interval_ms: :timer.hours(24)]})

Comment thread
beardedeagle marked this conversation as resolved.
Outdated
:ok
end

describe "refresh_for_workspace/1" do
test "returns {:error, :no_backends_discovered} when workspace has no secrets" do
workspace = insert_workspace!()
assert {:error, :no_backends_discovered} = ModelRegistry.refresh_for_workspace(workspace.id)
end

test "returns {:error, :no_backends_discovered} when secrets have no provider" do
workspace = insert_workspace!()
_secret = insert_vault_secret!(workspace, %{name: "misc_key"})

assert {:error, :no_backends_discovered} = ModelRegistry.refresh_for_workspace(workspace.id)
end

test "returns {:error, :no_backends_discovered} for unknown provider" do
workspace = insert_workspace!()
_secret = insert_vault_secret!(workspace, %{name: "local_key", provider: "local"})

assert {:error, :no_backends_discovered} = ModelRegistry.refresh_for_workspace(workspace.id)
end

test "discovers anthropic secret and attempts probe (fails on unreachable host)" do
workspace = insert_workspace!()

_secret =
Comment thread
beardedeagle marked this conversation as resolved.
Outdated
insert_vault_secret!(workspace, %{
name: "anthropic_key",
value: "sk-fake",
provider: "anthropic"
})

# The probe will fail because there's no real API to reach,
# but the function should return :ok (probe failures are handled
# gracefully — the backend is still auto-configured).
assert :ok = ModelRegistry.refresh_for_workspace(workspace.id)
end
Comment thread
beardedeagle marked this conversation as resolved.
Outdated

test "discovers multiple providers and probes each backend" do
workspace = insert_workspace!()

_anthropic =
insert_vault_secret!(workspace, %{
name: "anthropic_key",
value: "sk-fake-1",
provider: "anthropic"
})

_openai =
insert_vault_secret!(workspace, %{
name: "openai_key",
value: "sk-fake-2",
provider: "openai"
})

assert :ok = ModelRegistry.refresh_for_workspace(workspace.id)
end

test "deduplicates when multiple secrets map to the same backend" do
workspace = insert_workspace!()

_secret1 =
insert_vault_secret!(workspace, %{
name: "anthropic_prod",
value: "sk-prod",
provider: "anthropic"
})

_secret2 =
insert_vault_secret!(workspace, %{
name: "anthropic_dev",
value: "sk-dev",
provider: "anthropic"
})

# Should not crash — deduplication picks the first secret per backend
assert :ok = ModelRegistry.refresh_for_workspace(workspace.id)
end
end
end