diff --git a/lib/kodo/agent/loop.ex b/lib/kodo/agent/loop.ex
index 8569011..7f17caa 100644
--- a/lib/kodo/agent/loop.ex
+++ b/lib/kodo/agent/loop.ex
@@ -159,10 +159,10 @@ defmodule Kodo.Agent.Loop do
review = ModelMapping.role!(context.mapping, :review)
review_model = ModelMapping.request_model(review)
contract = Roles.fetch!(:review, review["role_contract"])
+ review_invocation_id = Ecto.UUID.generate()
with {:ok, capability_validation} <-
context.adapter.validate_model(review_model, review, contract),
- {:ok, request} <- resolve_request(context.session_id, review),
:ok <-
Phoenix.PubSub.subscribe(
Kodo.PubSub,
@@ -170,17 +170,6 @@ defmodule Kodo.Agent.Loop do
),
:ok <- Phoenix.PubSub.subscribe(Kodo.PubSub, "session:#{context.session_id}"),
:ok <- Runners.subscribe_lifecycle(),
- {:ok, {review_invocation_id, admission}} <-
- start_review_invocation(
- context.session_id,
- primary_invocation_id,
- context.mapping,
- review,
- contract,
- capability_validation,
- request,
- context.ownership
- ),
{:ok, diff} <-
execute_tool(
context.session_id,
@@ -192,6 +181,17 @@ defmodule Kodo.Agent.Loop do
context.ownership
),
:ok <- rehoming_boundary(),
+ {:ok, request} <- resolve_request(context.session_id, review),
+ {:ok, admission} <-
+ start_review_invocation(
+ context,
+ primary_invocation_id,
+ review_invocation_id,
+ review,
+ contract,
+ capability_validation,
+ request
+ ),
{:ok, generated} <-
Sessions.dispatch_if_owner(context.ownership, fn ->
task = original_task(context.events)
@@ -237,19 +237,16 @@ defmodule Kodo.Agent.Loop do
end
defp start_review_invocation(
- session_id,
+ context,
primary_invocation_id,
- mapping,
+ invocation_id,
review,
contract,
capability_validation,
- request,
- ownership
+ request
) do
- invocation_id = Ecto.UUID.generate()
-
case Sessions.append_admitted_model_event(
- session_id,
+ context.session_id,
"review_invocation_started",
%{
"invocation_id" => invocation_id,
@@ -264,15 +261,15 @@ defmodule Kodo.Agent.Loop do
"role_contract" => contract.id,
"toolset_version" => contract.toolset_version,
"capability_validation" => capability_validation,
- "model_mapping" => mapping
+ "model_mapping" => context.mapping
},
request.model,
request.credential,
version: 2,
parent_id: primary_invocation_id,
- ownership: ownership
+ ownership: context.ownership
) do
- {:ok, {_event, admission}} -> {:ok, {invocation_id, admission}}
+ {:ok, {_event, admission}} -> {:ok, admission}
error -> normalize_admission_error(error, request)
end
end
diff --git a/lib/kodo/agent/model_mapping.ex b/lib/kodo/agent/model_mapping.ex
index c7fe6b3..6d4b0b8 100644
--- a/lib/kodo/agent/model_mapping.ex
+++ b/lib/kodo/agent/model_mapping.ex
@@ -67,11 +67,14 @@ defmodule Kodo.Agent.ModelMapping do
roles =
Map.new(mapping["roles"], fn {role, role_mapping} ->
contract = Roles.fetch!(role_atom(role), role_mapping["role_contract"])
+ {route, selector} = model_route_and_selector(role_mapping)
{role,
role_mapping
- |> Map.put("execution_route", role_mapping["provider"])
- |> Map.put("model_selector", model_selector(role_mapping["model"]))
+ |> Map.put("provider", route)
+ |> Map.put("execution_route", route)
+ |> Map.put("model", canonical_model(route, selector, role_mapping["model"]))
+ |> Map.put("model_selector", selector)
|> Map.put_new("capability_contract", capability_contract(contract))}
end)
@@ -249,10 +252,41 @@ defmodule Kodo.Agent.ModelMapping do
end
end
- defp model_selector(model) do
- case String.split(model, ":", parts: 2) do
- [_provider, selector] -> selector
- [selector] -> selector
+ defp model_route_and_selector(%{
+ "execution_route" => route,
+ "model_selector" => selector
+ })
+ when is_binary(route) and route != "" and is_binary(selector) and selector != "",
+ do: {route, selector}
+
+ defp model_route_and_selector(%{"model" => model, "provider" => provider})
+ when is_binary(model) and is_binary(provider) and provider != "" do
+ accepted_prefix =
+ [provider, String.replace(provider, "_", "-")]
+ |> Enum.uniq()
+ |> Enum.find(&String.starts_with?(model, &1 <> ":"))
+
+ if accepted_prefix do
+ {provider, String.replace_prefix(model, accepted_prefix <> ":", "")}
+ else
+ parse_model_route_and_selector(model, provider)
+ end
+ end
+
+ defp model_route_and_selector(%{"model" => model, "provider" => provider}) do
+ parse_model_route_and_selector(model, provider)
+ end
+
+ defp parse_model_route_and_selector(model, provider) do
+ case LLMDB.Spec.parse_spec(model) do
+ {:ok, {parsed_provider, selector}} -> {Atom.to_string(parsed_provider), selector}
+ {:error, _reason} -> {provider, model}
end
end
+
+ defp canonical_model(route, selector, _original)
+ when is_binary(route) and route != "" and is_binary(selector) and selector != "",
+ do: "#{route}:#{selector}"
+
+ defp canonical_model(_route, _selector, original), do: original
end
diff --git a/lib/kodo/integrations.ex b/lib/kodo/integrations.ex
index 346118a..6e882ab 100644
--- a/lib/kodo/integrations.ex
+++ b/lib/kodo/integrations.ex
@@ -251,6 +251,30 @@ defmodule Kodo.Integrations do
end
end
+ @doc "Returns bounded terminal authorization feedback without loading encrypted payloads."
+ def get_device_authorization_outcome(%Scope{user: user}, integration_id) do
+ with {:ok, integration_id} <- cast_uuid(integration_id) do
+ DeviceAuthorizationAttempt
+ |> join(:inner, [attempt], integration in assoc(attempt, :integration))
+ |> where(
+ [attempt, integration],
+ attempt.integration_id == ^integration_id and integration.user_id == ^user.id
+ )
+ |> order_by([attempt], desc: attempt.attempt_generation)
+ |> limit(1)
+ |> select([attempt], %{
+ attempt_generation: attempt.attempt_generation,
+ state: attempt.state,
+ terminal_error_code: attempt.terminal_error_code
+ })
+ |> Repo.one()
+ |> case do
+ %{state: state} = outcome when state in ["failed", "expired"] -> {:ok, outcome}
+ _missing_or_non_failure -> {:error, :device_authorization_outcome_not_found}
+ end
+ end
+ end
+
@doc false
def expire_device_authorization(%Scope{user: user}, integration_id) do
with {:ok, integration_id} <- cast_uuid(integration_id) do
@@ -441,6 +465,7 @@ defmodule Kodo.Integrations do
do: {:error, :authentication_type_mismatch}
defp insert_oauth_integration(user_id, changeset) do
+ lock_user!(user_id)
lock_provider_identity(user_id, "openai_codex")
case Repo.insert(changeset) do
@@ -782,6 +807,8 @@ defmodule Kodo.Integrations do
defp activate_transaction(user_id, id, generation, audit?) do
Repo.transaction(fn ->
+ lock_user!(user_id)
+
case lock_provider_integrations(user_id, id) do
{:ok, integration, provider_integrations} ->
activate_locked(user_id, integration, provider_integrations, generation, audit?)
@@ -1561,6 +1588,7 @@ defmodule Kodo.Integrations do
case Ecto.UUID.cast(id) do
{:ok, id} ->
Repo.transaction(fn ->
+ lock_user!(user.id)
maybe_lock_transition(user.id, id, audit_event_type)
{restore_active, changes} = Map.pop(changes, :restore_active, false)
@@ -1617,6 +1645,7 @@ defmodule Kodo.Integrations do
defp insert_connected(changeset, actor_user_id, provider, event_type) do
Repo.transaction(fn ->
+ lock_user!(actor_user_id)
lock_provider_identity(actor_user_id, provider)
first_account? = !provider_account_exists?(actor_user_id, provider)
@@ -1655,7 +1684,14 @@ defmodule Kodo.Integrations do
case Repo.get_by(Integration, id: integration_id, user_id: user_id) do
%Integration{provider: provider} ->
lock_provider_identity(user_id, provider)
- invalidate_provider_selection_recovery(user_id, provider)
+
+ case lock_provider_rows(user_id, provider, integration_id) do
+ {:ok, _integration, _provider_integrations} ->
+ invalidate_provider_selection_recovery(user_id, provider)
+
+ {:error, reason} ->
+ Repo.rollback(reason)
+ end
nil ->
:ok
diff --git a/lib/kodo/sessions.ex b/lib/kodo/sessions.ex
index 2db0f59..52b4fb4 100644
--- a/lib/kodo/sessions.ex
+++ b/lib/kodo/sessions.ex
@@ -588,13 +588,14 @@ defmodule Kodo.Sessions do
end
defp preflight_turn(scope, session_id) do
- mapping =
- session_id
- |> get_session!()
- |> turn_route_snapshot()
- |> Map.fetch!("model_mapping")
+ case get_session(scope, session_id) do
+ %Session{} = session ->
+ mapping = session |> turn_route_snapshot() |> Map.fetch!("model_mapping")
+ preflight_mapping(scope, mapping)
- preflight_mapping(scope, mapping)
+ nil ->
+ {:error, :session_not_found}
+ end
end
defp preflight_mapping(scope, mapping) do
@@ -643,7 +644,7 @@ defmodule Kodo.Sessions do
end
defp append_admitted_model_event_locked(session_id, type, payload, model, credential, opts) do
- session = lock_session!(session_id, opts)
+ session = lock_session_after_owner!(session_id, opts)
integration = admit_model_credential!(session, credential)
billing_path = IntegrationRef.from_integration(integration).billing_path
@@ -732,8 +733,17 @@ defmodule Kodo.Sessions do
end
defp reconcile_cancel({:error, reason}, scope, session_id)
- when reason in [:not_running, :already_finished, :coordinator_unavailable] do
- if get_session(scope, session_id).status == "cancelled", do: :ok, else: {:error, reason}
+ when reason in [
+ :not_running,
+ :already_finished,
+ :coordinator_unavailable,
+ :session_not_found
+ ] do
+ case get_session(scope, session_id) do
+ %Session{status: "cancelled"} -> :ok
+ %Session{} -> {:error, reason}
+ nil -> {:error, :not_found}
+ end
end
defp reconcile_cancel(result, _scope, _session_id), do: result
@@ -1090,7 +1100,7 @@ defmodule Kodo.Sessions do
end
defp begin_turn_locked(session_id, content, client_request_id, opts) do
- session = lock_session!(session_id, opts)
+ session = lock_session_after_owner!(session_id, opts)
if turn_request_recorded?(session_id, client_request_id) do
[]
@@ -1149,6 +1159,7 @@ defmodule Kodo.Sessions do
end
defp change_execution_route_locked(scope, session_id, destination) do
+ lock_scope_user!(scope)
session = lock_user_session!(scope, session_id)
projection = session.id |> events_after() |> Projection.from_events()
@@ -1498,17 +1509,55 @@ defmodule Kodo.Sessions do
Session
|> where([session], session.id == ^session_id)
|> lock("FOR UPDATE")
- |> Repo.one!()
+ |> Repo.one()
ownership = Keyword.get(opts, :ownership)
cond do
+ is_nil(session) -> Repo.rollback(:session_not_found)
Keyword.get(opts, :allow_unowned, false) -> session
ownership_matches?(session, ownership) and owner_alive?(ownership) -> session
true -> Repo.rollback(:stale_ownership)
end
end
+ defp lock_session_after_owner!(session_id, opts) do
+ owner =
+ Session
+ |> where([session], session.id == ^session_id)
+ |> select([session], {session.id, session.user_id})
+ |> Repo.one()
+
+ case owner do
+ {^session_id, user_id} when is_integer(user_id) ->
+ lock_user!(user_id)
+ session = lock_session!(session_id, opts)
+
+ if session.user_id == user_id,
+ do: session,
+ else: Repo.rollback(:session_not_found)
+
+ {^session_id, nil} ->
+ lock_session!(session_id, opts)
+
+ nil ->
+ Repo.rollback(:session_not_found)
+ end
+ end
+
+ defp lock_scope_user!(%Scope{user: user}), do: lock_user!(user.id)
+
+ defp lock_user!(user_id) do
+ case User
+ |> where([user], user.id == ^user_id)
+ |> select([user], user.id)
+ |> lock("FOR KEY SHARE")
+ |> Repo.one() do
+ ^user_id -> :ok
+ nil -> Repo.rollback(:session_not_found)
+ end
+ end
+
defp lock_user_session!(%Scope{user: user}, session_id) do
case Ecto.UUID.cast(session_id) do
{:ok, session_id} ->
@@ -1583,11 +1632,13 @@ defmodule Kodo.Sessions do
from session in Session, where: session.id == ^event.session_id, select: session.user_id
)
- Phoenix.PubSub.broadcast(
- Kodo.PubSub,
- session_index_topic(user_id),
- {:session_index_changed, event.session_id}
- )
+ if user_id do
+ Phoenix.PubSub.broadcast(
+ Kodo.PubSub,
+ session_index_topic(user_id),
+ {:session_index_changed, event.session_id}
+ )
+ end
end
end
diff --git a/lib/kodo/sessions/active_session.ex b/lib/kodo/sessions/active_session.ex
index d101ce7..cfb2621 100644
--- a/lib/kodo/sessions/active_session.ex
+++ b/lib/kodo/sessions/active_session.ex
@@ -122,6 +122,9 @@ defmodule Kodo.Sessions.ActiveSession do
{:error, :stale_ownership} = error ->
{:stop, :normal, error, stop_task(state)}
+ {:error, :session_not_found} = error ->
+ {:stop, :normal, error, stop_task(state)}
+
{:error, reason} ->
{:reply, {:error, reason}, state}
end
@@ -137,6 +140,9 @@ defmodule Kodo.Sessions.ActiveSession do
{:error, :stale_ownership} = error ->
{:stop, :normal, error, stop_task(state)}
+ {:error, :session_not_found} = error ->
+ {:stop, :normal, error, stop_task(state)}
+
{:error, reason} ->
{:reply, {:error, reason}, state}
end
@@ -161,6 +167,9 @@ defmodule Kodo.Sessions.ActiveSession do
{:error, :stale_ownership} = error ->
{:stop, :normal, error, stop_task(state)}
+ {:error, :session_not_found} = error ->
+ {:stop, :normal, error, %{state | task: nil}}
+
{:error, reason} ->
{:stop, {:cancellation_persistence_failed, reason}, {:error, reason}, state}
end
@@ -243,6 +252,10 @@ defmodule Kodo.Sessions.ActiveSession do
reply_drain_waiters(state, :ok)
{:stop, :normal, state |> Map.put(:task, nil) |> Map.delete(:drain_waiters)}
+ {:error, :session_not_found} ->
+ reply_drain_waiters(state, :ok)
+ {:stop, :normal, state |> Map.put(:task, nil) |> Map.delete(:drain_waiters)}
+
{:error, :stale_ownership} ->
reply_drain_waiters(state, {:error, :stale_ownership})
{:stop, :normal, stop_task(state)}
@@ -261,6 +274,9 @@ defmodule Kodo.Sessions.ActiveSession do
{:error, :session_not_active} ->
{:stop, :normal, {:error, :already_finished}, %{state | task: nil}}
+ {:error, :session_not_found} ->
+ {:stop, :normal, {:error, :session_not_found}, %{state | task: nil}}
+
{:error, :stale_ownership} = error ->
{:stop, :normal, error, stop_task(state)}
diff --git a/lib/kodo_web/live/integrations_live.ex b/lib/kodo_web/live/integrations_live.ex
index 6185d72..f01b575 100644
--- a/lib/kodo_web/live/integrations_live.ex
+++ b/lib/kodo_web/live/integrations_live.ex
@@ -65,6 +65,7 @@ defmodule KodoWeb.IntegrationsLive do
|> assign(:validation_tasks, %{})
|> assign(:device_authorization_tasks, %{})
|> assign(:device_authorization_retries, %{})
+ |> assign(:device_authorization_outcomes, %{})
|> load_integrations()
socket =
@@ -234,7 +235,11 @@ defmodule KodoWeb.IntegrationsLive do
) do
{:noreply,
socket
- |> track_device_authorization_task(started.task, integration.id)
+ |> track_device_authorization_task(
+ started.task,
+ integration.id,
+ started.attempt.attempt_generation
+ )
|> put_flash(:info, "Authorization started. Enter the one-time code with OpenAI.")
|> push_patch(to: ~p"/integrations")}
else
@@ -375,16 +380,10 @@ defmodule KodoWeb.IntegrationsLive do
{:device_authorization_changed, integration_id, attempt_generation, state, _error},
socket
) do
- socket =
- socket
- |> maybe_clear_device_authorization_retry(integration_id, attempt_generation, state)
- |> load_integrations()
-
- if state == "failed" do
- {:noreply, put_flash(socket, :error, "Authorization failed. Try again.")}
- else
- {:noreply, socket}
- end
+ {:noreply,
+ socket
+ |> maybe_clear_device_authorization_retry(integration_id, attempt_generation, state)
+ |> load_integrations()}
end
def handle_info({:retry_device_authorization, integration_id, attempt_generation}, socket) do
@@ -479,9 +478,13 @@ defmodule KodoWeb.IntegrationsLive do
device_authorizations = load_device_authorizations(socket.assigns.current_scope, integrations)
+ device_authorization_outcomes =
+ load_device_authorization_outcomes(socket.assigns.current_scope, integrations)
+
socket
|> assign(:integrations, integrations)
|> assign(:device_authorizations, device_authorizations)
+ |> assign(:device_authorization_outcomes, device_authorization_outcomes)
|> reconcile_device_authorization_retries()
end
@@ -501,7 +504,28 @@ defmodule KodoWeb.IntegrationsLive do
end
end)
- assign(socket, :device_authorization_retries, retries)
+ socket = assign(socket, :device_authorization_retries, retries)
+
+ tracked_keys = MapSet.new(socket.assigns.device_authorization_tasks, &elem(&1, 1))
+
+ if connected?(socket) do
+ Enum.reduce(active_keys, socket, fn key, acc ->
+ ensure_device_authorization_retry(acc, key, retries, tracked_keys)
+ end)
+ else
+ socket
+ end
+ end
+
+ defp ensure_device_authorization_retry(
+ socket,
+ {integration_id, generation} = key,
+ retries,
+ tracked_keys
+ ) do
+ if Map.has_key?(retries, key) or MapSet.member?(tracked_keys, key),
+ do: socket,
+ else: schedule_device_authorization_retry(socket, integration_id, generation)
end
# Browser-facing state needs lifecycle metadata only. In particular, keeping
@@ -538,9 +562,9 @@ defmodule KodoWeb.IntegrationsLive do
update(socket, :validation_tasks, &Map.delete(&1, reference))
end
- defp track_device_authorization_task(socket, task, integration_id) do
+ defp track_device_authorization_task(socket, task, integration_id, attempt_generation) do
update(socket, :device_authorization_tasks, fn tasks ->
- Map.put(tasks, task.ref, integration_id)
+ Map.put(tasks, task.ref, {integration_id, attempt_generation})
end)
end
@@ -559,7 +583,7 @@ defmodule KodoWeb.IntegrationsLive do
{:ok, task} ->
socket
|> clear_device_authorization_retry(integration_id, attempt_generation)
- |> track_device_authorization_task(task, integration_id)
+ |> track_device_authorization_task(task, integration_id, attempt_generation)
{:error, :device_authorization_not_claimable} when schedule_retry? ->
schedule_device_authorization_retry(socket, integration_id, attempt_generation)
@@ -634,6 +658,34 @@ defmodule KodoWeb.IntegrationsLive do
end)
end
+ defp load_device_authorization_outcomes(scope, integrations) do
+ integrations
+ |> Enum.filter(&(&1.provider == "openai_codex"))
+ |> Map.new(fn integration ->
+ outcome =
+ case Integrations.get_device_authorization_outcome(scope, integration.id) do
+ {:ok, %{attempt_generation: generation, state: "failed"}} ->
+ %{
+ attempt_generation: generation,
+ message: "OpenAI could not complete authorization. Try again."
+ }
+
+ {:ok, %{attempt_generation: generation, state: "expired"}} ->
+ %{
+ attempt_generation: generation,
+ message: "Authorization expired. Start again to get a new one-time code."
+ }
+
+ {:error, :device_authorization_outcome_not_found} ->
+ nil
+ end
+
+ {integration.id, outcome}
+ end)
+ |> Enum.reject(fn {_integration_id, outcome} -> is_nil(outcome) end)
+ |> Map.new()
+ end
+
defp find_device_authorization(authorizations, attempt_id) do
Enum.find_value(authorizations, fn {_integration_id, attempt} ->
if attempt.attempt_id == attempt_id, do: attempt
@@ -945,6 +997,14 @@ defmodule KodoWeb.IntegrationsLive do
>
{validation_detail(integration)}
+
+ {outcome.message}
+
One-time code
-
+
{authorization.user_code}
+
@@ -1311,9 +1377,16 @@ defmodule KodoWeb.IntegrationsLive do
export default {
mounted() {
this.el.addEventListener("click", async () => {
- await navigator.clipboard.writeText(this.el.dataset.copyText)
- this.el.textContent = "Copied"
- window.setTimeout(() => { this.el.textContent = "Copy" }, 1500)
+ const status = this.el.nextElementSibling
+
+ try {
+ await navigator.clipboard.writeText(this.el.dataset.copyText)
+ this.el.textContent = "Copied"
+ status.textContent = "One-time code copied."
+ window.setTimeout(() => { this.el.textContent = "Copy" }, 1500)
+ } catch (_error) {
+ status.textContent = "Could not copy. Select and copy the code manually."
+ }
})
}
}
diff --git a/rfd/0005/IMPLEMENTATION.org b/rfd/0005/IMPLEMENTATION.org
index 3780fcd..c6030f2 100644
--- a/rfd/0005/IMPLEMENTATION.org
+++ b/rfd/0005/IMPLEMENTATION.org
@@ -327,7 +327,8 @@ manufactured after that no-go decision.
- [X] Render the fixed allowlisted HTTPS verification destination and one-time
code separately, reject a provider-returned verification URL that does not
match the exact allowlist, and never automatically navigate to that URL.
-- [X] Trigger claims only from attempt creation or LiveView mount/remount, use
+- [X] Trigger claims from connection-driven attempt creation, LiveView
+ mount/remount, generation-specific retry, and task-completion recovery; use
one conditional update against the exact attempt row that increments and
returns its claim epoch, and run a successful claim as a finite task under
~Kodo.ControlPlaneTaskSupervisor~ that survives the originating LiveView
@@ -346,9 +347,9 @@ manufactured after that no-go decision.
generation. Starting reauthorization advances the integration generation and
the new attempt captures that post-advance value; install OAuth credentials
only when both attempt and expected integration generations still match.
-- [X] Store access tokens, refresh tokens, account identifiers, expiries, and
- credential generations in the encrypted integration payload without logging
- device or token material.
+- [X] Store access tokens, refresh tokens, and account identifiers in the
+ encrypted integration payload. Keep expiries and credential generations as
+ queryable lifecycle metadata, without logging device or token material.
- [X] Test user deletion with OAuth credentials, an active authorization
attempt, a held claim, and in-flight refresh; all rows cascade and every late
completion becomes a no-op.
@@ -388,8 +389,10 @@ manufactured after that no-go decision.
has an accessible Copy action, is retained only while needed to present the
active attempt, and disappears after every terminal state without entering
logs or telemetry.
-- [X] Extend onboarding and missing-provider feedback for ChatGPT Subscription,
- and emit credential-free audit events for authorization start/completion and
+- [ ] Extend onboarding for ChatGPT Subscription. The baseline onboarding and
+ confirmed model-preset interaction remain Phase 1B and Phase 2 work.
+- [X] Extend missing-provider feedback for ChatGPT Subscription and emit
+ credential-free audit events for authorization start/completion and
cancellation, refresh outcomes, reauthorization, and disconnection.
- [ ] Add an explicit per-session execution-route change between OpenAI
Platform and ChatGPT Subscription only when the destination supports every
@@ -411,6 +414,25 @@ manufactured after that no-go decision.
- [X] Route requests whose persisted turn snapshot selects ~openai_codex~ only
through the user's ChatGPT integration and prohibit silent fallback to OpenAI
API billing.
+
+Production generation entry points consume a ~Kodo.LLM.InvocationAdmission~
+returned by the session transaction that records invocation start and canonical
+billing provenance. Scoped credential resolution and decryption precede that
+transaction; the transaction rechecks session ownership, integration state, and
+credential generation before permitting inference. The admission struct makes
+that internal boundary explicit, but is not an unforgeable bearer capability
+because BEAM modules can construct structs. The separately named evaluation
+entry points are out-of-band harness operations and do not represent production
+session admission. Turn acceptance holds deterministic provider-selection and
+active-row locks while validating the complete role set; those locks end at
+commit and are never held across inference.
+
+Device and refresh recovery also preserve already-admitted provider results
+while bounding automatic expired-token recovery. Explicit activation or
+disconnection invalidates older selection-restoration intent, expired device
+attempts cannot poison refresh admission, and open LiveViews reconcile polling,
+exchange, terminal, and superseded attempt state from durable storage.
+
- [ ] Test authorization ownership and expiry, polling backoff, two-node refresh
contention, multi-node and concurrent-tab device claims, claim epoch takeover
and conditional renewal, stale claimant polling and completion, pauses before
diff --git a/test/kodo/agent/loop_test.exs b/test/kodo/agent/loop_test.exs
index 183a91a..e9602c6 100644
--- a/test/kodo/agent/loop_test.exs
+++ b/test/kodo/agent/loop_test.exs
@@ -306,8 +306,17 @@ defmodule Kodo.Agent.LoopTest do
[created] = Sessions.events_after(session.id)
+ session
+ |> Ecto.Changeset.change(model: "gpt-4o-mini@openai")
+ |> Repo.update!()
+
+ legacy_payload =
+ created.payload
+ |> Map.delete("model_mapping")
+ |> Map.put("model", "gpt-4o-mini@openai")
+
created
- |> Ecto.Changeset.change(payload: Map.delete(created.payload, "model_mapping"))
+ |> Ecto.Changeset.change(payload: legacy_payload)
|> Repo.update!()
{:ok, _event} =
@@ -914,6 +923,11 @@ defmodule Kodo.Agent.LoopTest do
assert_receive {:tool_request, request}
assert request["request"] == %{"tool" => "git_diff", "paths" => []}
+ refute Enum.any?(
+ Sessions.events_after(session.id),
+ &(&1.type == "review_invocation_started")
+ )
+
broadcast_success(runner, request, %{
"result" => "output",
"content" => "clean diff",
@@ -951,6 +965,73 @@ defmodule Kodo.Agent.LoopTest do
end)
end
+ test "resolves and admits the review credential after the final diff is available", %{
+ runner: runner,
+ scope: scope
+ } do
+ {:ok, _registration} = Registry.register(Kodo.RunnerRegistry, runner.id, nil)
+
+ assert {:ok, anthropic} =
+ Integrations.connect(scope, "anthropic", "api_key", %{"api_key" => "review-key"})
+
+ assert {:ok, _override} =
+ ModelSettings.put_user_override(scope, :review, %{
+ model: "anthropic:claude-3-5-haiku-latest"
+ })
+
+ assert {:ok, session} =
+ Sessions.create_session(scope, %{
+ runner_id: runner.id,
+ title: "Fresh review credential",
+ model: "openai:gpt-4o-mini"
+ })
+
+ assert {:ok, ownership} = Sessions.claim_ownership(session.id, nil)
+
+ assert {:ok, _event} =
+ Sessions.append_event(
+ session.id,
+ "user_message",
+ %{"role" => "user", "content" => "final answer"},
+ ownership: ownership
+ )
+
+ loop =
+ Task.async(fn ->
+ Loop.run(session.id,
+ adapter: Kodo.Test.FakeLLM,
+ budgets: budgets([]),
+ ownership: ownership
+ )
+ end)
+
+ assert_receive {:tool_request, request}
+ assert request["request"]["tool"] == "git_diff"
+
+ refute Enum.any?(
+ Sessions.events_after(session.id),
+ &(&1.type == "review_invocation_started")
+ )
+
+ assert {:ok, _disconnected} =
+ Integrations.disconnect(scope, anthropic.id, anthropic.credential_generation)
+
+ broadcast_success(runner, request, %{
+ "result" => "output",
+ "content" => "clean diff",
+ "truncated" => false
+ })
+
+ assert {:error, %Kodo.LLM.ProviderError{} = error} = Task.await(loop)
+ assert error.provider == "anthropic"
+ assert error.kind == :integration_required
+
+ refute Enum.any?(
+ Sessions.events_after(session.id),
+ &(&1.type == "review_invocation_started")
+ )
+ end
+
test "feeds supported review findings back to primary and reviews the correction", %{
runner: runner,
session: session,
diff --git a/test/kodo/agent/model_mapping_test.exs b/test/kodo/agent/model_mapping_test.exs
index 7f28d43..eb8befe 100644
--- a/test/kodo/agent/model_mapping_test.exs
+++ b/test/kodo/agent/model_mapping_test.exs
@@ -71,6 +71,42 @@ defmodule Kodo.Agent.ModelMappingTest do
ModelMapping.validate_snapshot(malformed)
end
+ test "canonicalizes every supported string model specification in snapshots" do
+ specifications = [
+ {"openai:gpt-4o-mini", "openai", "gpt-4o-mini"},
+ {"gpt-4o-mini@openai", "openai", "gpt-4o-mini"},
+ {"openrouter:nvidia/nemotron-3-super-120b-a12b:free", "openrouter",
+ "nvidia/nemotron-3-super-120b-a12b:free"},
+ {"nvidia/nemotron-3-super-120b-a12b:free@openrouter", "openrouter",
+ "nvidia/nemotron-3-super-120b-a12b:free"}
+ ]
+
+ for {specification, route, selector} <- specifications do
+ mapping = ModelMapping.balanced([{"session", %{primary: %{model: specification}}}])
+ snapshot = ModelMapping.snapshot(mapping)
+ primary = snapshot["roles"]["primary"]
+
+ assert primary["model"] == "#{route}:#{selector}"
+ assert primary["execution_route"] == route
+ assert primary["model_selector"] == selector
+ assert {:ok, ^snapshot} = ModelMapping.validate_snapshot(snapshot)
+ end
+ end
+
+ test "canonicalizes application-defined execution route spellings idempotently" do
+ for specification <- ["openai_codex:gpt-5.4", "openai-codex:gpt-5.4"] do
+ mapping = ModelMapping.balanced([{"session", %{primary: %{model: specification}}}])
+
+ snapshot = ModelMapping.snapshot(mapping)
+ primary = snapshot["roles"]["primary"]
+
+ assert primary["model"] == "openai_codex:gpt-5.4"
+ assert primary["model_selector"] == "gpt-5.4"
+ assert ModelMapping.snapshot(snapshot) == snapshot
+ assert {:ok, ^snapshot} = ModelMapping.validate_snapshot(snapshot)
+ end
+ end
+
test "rejects malformed capability requirements instead of accepting a partial envelope" do
snapshot = ModelMapping.snapshot(ModelMapping.balanced())
diff --git a/test/kodo/integrations/oauth_refresh_test.exs b/test/kodo/integrations/oauth_refresh_test.exs
index 2b97e81..91f417f 100644
--- a/test/kodo/integrations/oauth_refresh_test.exs
+++ b/test/kodo/integrations/oauth_refresh_test.exs
@@ -35,7 +35,8 @@ defmodule Kodo.Integrations.OAuthRefreshTest do
end
test "refreshes shortly before expiry and persists rotated token state", context do
- client = fake_client([{:ok, rotated_tokens("account")}])
+ rotated = rotated_tokens("account")
+ client = fake_client([{:ok, rotated}])
assert {:ok, refreshed} = refresh(context, client)
assert refreshed.credential_generation == context.integration.credential_generation + 1
@@ -45,7 +46,7 @@ defmodule Kodo.Integrations.OAuthRefreshTest do
assert is_nil(refreshed.refresh_claim_owner_id)
assert {:ok, credentials} = CredentialEncryption.decrypt(refreshed)
- assert credentials["access_token"] == rotated_tokens("account")["access_token"]
+ assert credentials["access_token"] == rotated["access_token"]
assert credentials["refresh_token"] == "rotated-refresh"
assert credentials["account_id"] == "account"
assert Agent.get(client, &Enum.reverse(&1.refresh_tokens)) == ["old-refresh"]
@@ -317,7 +318,7 @@ defmodule Kodo.Integrations.OAuthRefreshTest do
assert credentials["refresh_token"] == "usable-refresh"
end
- test "bounds automatic recovery when refreshed access is already expired", context do
+ test "does not start another refresh after expired-success recovery is exhausted", context do
admitted_at = DateTime.add(DateTime.utc_now(), -10, :second)
expired =
@@ -327,7 +328,7 @@ defmodule Kodo.Integrations.OAuthRefreshTest do
jwt(%{"exp" => DateTime.utc_now() |> DateTime.to_unix() |> Kernel.-(1)})
)
- client = fake_client([{:ok, expired}, {:ok, expired}])
+ client = fake_client([{:ok, expired}, {:ok, rotated_tokens("account", "unused")}])
assert {:error, :provider_unavailable} =
OAuthRefresh.ensure_fresh(context.scope, context.integration,
@@ -336,11 +337,15 @@ defmodule Kodo.Integrations.OAuthRefreshTest do
client: FakeRefreshClient,
client_options: [agent: client],
supervisor: context.supervisor,
- claim_owner_id: Ecto.UUID.generate()
+ claim_owner_id: Ecto.UUID.generate(),
+ expired_success_retries: 0
)
_ = :sys.get_state(client)
- assert length(Agent.get(client, & &1.refresh_tokens)) == 2
+ assert Agent.get(client, &Enum.reverse(&1.refresh_tokens)) == ["old-refresh"]
+
+ persisted = Repo.reload!(context.integration)
+ assert persisted.credential_generation == context.integration.credential_generation + 1
end
test "persists a rotated response after its caller stops waiting", context do
diff --git a/test/kodo/integrations_test.exs b/test/kodo/integrations_test.exs
index 148fc67..505f6ee 100644
--- a/test/kodo/integrations_test.exs
+++ b/test/kodo/integrations_test.exs
@@ -136,7 +136,7 @@ defmodule Kodo.IntegrationsTest do
test "returns a bounded error when connection races account deletion", %{scope: scope} do
Repo.delete!(scope.user)
- assert {:error, :integration_owner_not_found} = connect(scope)
+ assert {:error, :integration_not_found} = connect(scope)
end
test "replaces credentials with a new nonce and advances the generation", %{scope: scope} do
@@ -655,7 +655,7 @@ defmodule Kodo.IntegrationsTest do
Repo.delete!(scope.user)
send(task.pid, {:continue_json_encoding, ref})
- assert {:error, :stale_credential_generation} = Task.await(task)
+ assert {:error, :integration_not_found} = Task.await(task)
refute Repo.get(Integration, integration.id)
end
@@ -1503,6 +1503,141 @@ defmodule Kodo.IntegrationsTest do
Integrations.get_active_integration_by_provider(scope, "openai_codex")
end
+ test "disconnect does not invert integration and device-attempt lock order" do
+ parent = self()
+
+ {scope, integration, attempt} =
+ Task.async(fn ->
+ Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn ->
+ scope = AccountsFixtures.user_scope_fixture()
+ integration = oauth_integration(scope)
+
+ {:ok, attempt} =
+ Integrations.begin_device_authorization(
+ scope,
+ integration.id,
+ integration.credential_generation,
+ %{"device_auth_id" => "device", "user_code" => "CODE"},
+ 30_000
+ )
+
+ {scope, Repo.reload!(integration), attempt}
+ end)
+ end)
+ |> Task.await()
+
+ on_exit(fn ->
+ Task.async(fn ->
+ Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn -> Repo.delete(scope.user) end)
+ end)
+ |> Task.await()
+ end)
+
+ integration_holder =
+ Task.async(fn ->
+ Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn ->
+ Repo.transaction(fn ->
+ Ecto.Adapters.SQL.query!(
+ Repo,
+ "SELECT id FROM provider_integrations WHERE id = $1 FOR UPDATE",
+ [Ecto.UUID.dump!(integration.id)]
+ )
+
+ send(parent, {:integration_locked, self()})
+
+ receive do
+ :lock_attempt ->
+ Ecto.Adapters.SQL.query!(
+ Repo,
+ "SELECT id FROM device_authorization_attempts WHERE id = $1 FOR UPDATE",
+ [Ecto.UUID.dump!(attempt.id)]
+ )
+ end
+ end)
+ end)
+ end)
+
+ assert_receive {:integration_locked, holder}
+
+ disconnect =
+ Task.async(fn ->
+ Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn ->
+ Repo.transaction(fn ->
+ %{rows: [[backend_pid]]} = Ecto.Adapters.SQL.query!(Repo, "SELECT pg_backend_pid()")
+ send(parent, {:disconnect_backend, backend_pid})
+
+ Integrations.disconnect(
+ scope,
+ integration.id,
+ integration.credential_generation
+ )
+ end)
+ end)
+ end)
+
+ assert_receive {:disconnect_backend, backend_pid}
+ assert_query_blocked!(backend_pid)
+ send(holder, :lock_attempt)
+
+ assert {:ok, {:ok, _integration}} = Task.await(disconnect)
+ assert {:ok, _result} = Task.await(integration_holder)
+ end
+
+ test "audited lifecycle transitions lock the user before integrations" do
+ parent = self()
+
+ {scope, integration} =
+ Task.async(fn ->
+ Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn ->
+ scope = AccountsFixtures.user_scope_fixture()
+ {:ok, integration} = connect(scope, "lifecycle-key")
+ {scope, integration}
+ end)
+ end)
+ |> Task.await()
+
+ user_holder =
+ Task.async(fn ->
+ Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn ->
+ Repo.transaction(fn ->
+ Ecto.Adapters.SQL.query!(Repo, "SELECT id FROM users WHERE id = $1 FOR UPDATE", [
+ scope.user.id
+ ])
+
+ send(parent, {:user_locked, self()})
+
+ receive do
+ :delete_user -> Repo.delete!(scope.user)
+ end
+ end)
+ end)
+ end)
+
+ assert_receive {:user_locked, holder}
+
+ disconnect =
+ Task.async(fn ->
+ Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn ->
+ %{rows: [[backend_pid]]} = Ecto.Adapters.SQL.query!(Repo, "SELECT pg_backend_pid()")
+ send(parent, {:lifecycle_backend, backend_pid})
+
+ Integrations.disconnect(
+ scope,
+ integration.id,
+ integration.credential_generation
+ )
+ end)
+ end)
+
+ assert_receive {:lifecycle_backend, backend_pid}
+ assert_query_blocked!(backend_pid)
+ send(holder, :delete_user)
+
+ assert {:ok, _deleted} = Task.await(user_holder)
+ assert {:error, :integration_not_found} = Task.await(disconnect)
+ assert is_nil(Repo.get(Integration, integration.id))
+ end
+
test "cancellation is owned and generation fenced and removes the one-time code", %{
scope: scope
} do
@@ -1640,6 +1775,21 @@ defmodule Kodo.IntegrationsTest do
task
end
+ defp assert_query_blocked!(backend_pid, attempts \\ 100)
+
+ defp assert_query_blocked!(_backend_pid, 0), do: flunk("query did not block")
+
+ defp assert_query_blocked!(backend_pid, attempts) do
+ %{rows: [[blocked?]]} =
+ Ecto.Adapters.SQL.query!(
+ Repo,
+ "SELECT cardinality(pg_blocking_pids($1)) > 0",
+ [backend_pid]
+ )
+
+ if blocked?, do: :ok, else: assert_query_blocked!(backend_pid, attempts - 1)
+ end
+
defp blocking_value(owner, ref, value) do
%BlockingJSONValue{owner: owner, ref: ref, value: value}
end
diff --git a/test/kodo/sessions/active_session_test.exs b/test/kodo/sessions/active_session_test.exs
index 237da55..0acc434 100644
--- a/test/kodo/sessions/active_session_test.exs
+++ b/test/kodo/sessions/active_session_test.exs
@@ -278,6 +278,83 @@ defmodule Kodo.Sessions.ActiveSessionTest do
assert_receive {:DOWN, ^coordinator_ref, :process, ^coordinator, :normal}
end
+ test "stops normally when its user and session disappear during inference", %{
+ scope: scope,
+ session: session
+ } do
+ assert {:ok, coordinator} = Sessions.ensure_started(session.id)
+ assert :ok = ActiveSession.start_turn(coordinator, "wait")
+ assert_receive :fake_llm_waiting
+
+ task = :sys.get_state(coordinator).task.pid
+ coordinator_ref = Process.monitor(coordinator)
+
+ Repo.delete!(scope.user)
+ send(task, :never)
+
+ assert_receive {:DOWN, ^coordinator_ref, :process, ^coordinator, :normal}
+
+ refute Enum.any?(Registry.lookup(Kodo.SessionRegistry, session.id), fn {pid, _value} ->
+ Process.alive?(pid)
+ end)
+ end
+
+ test "stops its active task when a queued turn observes session deletion", %{
+ scope: scope,
+ session: session
+ } do
+ assert {:ok, coordinator} = Sessions.ensure_started(session.id)
+ assert :ok = ActiveSession.start_turn(coordinator, "wait")
+ assert_receive :fake_llm_waiting
+
+ task = :sys.get_state(coordinator).task.pid
+ task_ref = Process.monitor(task)
+ coordinator_ref = Process.monitor(coordinator)
+ Repo.delete!(scope.user)
+
+ assert {:error, :session_not_found} = ActiveSession.start_turn(coordinator, "queued turn")
+ assert_receive {:DOWN, ^task_ref, :process, ^task, :killed}
+ assert_receive {:DOWN, ^coordinator_ref, :process, ^coordinator, :normal}
+ end
+
+ test "cancellation stops normally when deletion wins before task shutdown", %{
+ scope: scope,
+ session: session
+ } do
+ assert {:ok, coordinator} = Sessions.ensure_started(session.id)
+ assert :ok = ActiveSession.start_turn(coordinator, "wait")
+ assert_receive :fake_llm_waiting
+ coordinator_ref = Process.monitor(coordinator)
+
+ Repo.delete!(scope.user)
+
+ assert {:error, :session_not_found} = ActiveSession.cancel(coordinator)
+ assert_receive {:DOWN, ^coordinator_ref, :process, ^coordinator, :normal}
+ end
+
+ test "cancellation preserves missing-session outcome from a completed task", %{
+ scope: scope,
+ session: session
+ } do
+ assert {:ok, coordinator} = Sessions.ensure_started(session.id)
+ assert :ok = ActiveSession.start_turn(coordinator, "wait")
+ assert_receive :fake_llm_waiting
+
+ task = :sys.get_state(coordinator).task.pid
+ task_ref = Process.monitor(task)
+ coordinator_ref = Process.monitor(coordinator)
+ :ok = :sys.suspend(coordinator)
+ reply_ref = make_ref()
+ send(coordinator, {:"$gen_call", {self(), reply_ref}, :cancel})
+ send(task, :never)
+ assert_receive {:DOWN, ^task_ref, :process, ^task, :normal}
+ Repo.delete!(scope.user)
+ :ok = :sys.resume(coordinator)
+
+ assert_receive {^reply_ref, {:error, :session_not_found}}
+ assert_receive {:DOWN, ^coordinator_ref, :process, ^coordinator, :normal}
+ end
+
test "reconciles a repeated turn request while its task is active", %{session: session} do
request_id = Ecto.UUID.generate()
assert {:ok, pid} = Sessions.ensure_started(session.id)
diff --git a/test/kodo/sessions_test.exs b/test/kodo/sessions_test.exs
index 50b840e..473aee8 100644
--- a/test/kodo/sessions_test.exs
+++ b/test/kodo/sessions_test.exs
@@ -1,11 +1,14 @@
defmodule Kodo.SessionsTest do
use Kodo.DataCase
+ alias Kodo.Accounts.User
alias Kodo.Agent.ExecutionRouteChange
alias Kodo.Agent.ModelMapping
alias Kodo.Agent.ModelSettings
alias Kodo.Cluster.Instances
alias Kodo.Integrations
+ alias Kodo.Integrations.CredentialEncryption
+ alias Kodo.Integrations.Integration
alias Kodo.LLM
alias Kodo.LLM.ProviderError
alias Kodo.Runners
@@ -365,6 +368,29 @@ defmodule Kodo.SessionsTest do
assert Enum.count(Sessions.events_after(session.id), &(&1.type == "user_message")) == 1
end
+ test "accepts alternate provider-qualified model syntax into a canonical snapshot", %{
+ runner: runner,
+ scope: scope
+ } do
+ assert {:ok, _integration} =
+ Integrations.connect(scope, "openai", "api_key", %{"api_key" => "alternate-key"})
+
+ assert {:ok, session} =
+ Sessions.create_session(scope, %{
+ runner_id: runner.id,
+ title: "Alternate model syntax",
+ model: "gpt-4o-mini@openai"
+ })
+
+ assert {:ok, [snapshot, _message, _status]} =
+ Sessions.begin_turn(session.id, "Accept supported syntax")
+
+ primary = snapshot.payload["model_mapping"]["roles"]["primary"]
+ assert primary["model"] == "openai:gpt-4o-mini"
+ assert primary["model_selector"] == "gpt-4o-mini"
+ assert primary["execution_route"] == "openai"
+ end
+
test "rejects a turn before acceptance when any configured role lacks access", %{
runner: runner,
scope: scope
@@ -421,12 +447,30 @@ defmodule Kodo.SessionsTest do
assert Enum.map(Sessions.events_after(session.id), & &1.type) == ["session_created"]
end
- test "holds provider selection locks through turn acceptance", %{
+ test "holds the provider selection lock acquired by turn acceptance", %{
runner: runner,
scope: scope
} do
- assert {:ok, _integration} =
- Integrations.connect(scope, "openai", "api_key", %{"api_key" => "locked-key"})
+ integration = %Integration{
+ id: Ecto.UUID.generate(),
+ user_id: scope.user.id,
+ provider: "openai",
+ authentication_type: "api_key"
+ }
+
+ assert {:ok, encrypted} =
+ CredentialEncryption.encrypt(integration, %{"api_key" => "locked-key"})
+
+ integration
+ |> change(
+ Map.merge(encrypted, %{
+ display_name: "Lock fixture",
+ active: true,
+ connection_status: "connected",
+ validation_status: "unverified"
+ })
+ )
+ |> Repo.insert!()
assert {:ok, session} =
Sessions.create_session(scope, %{
@@ -457,6 +501,75 @@ defmodule Kodo.SessionsTest do
refute lock_available?
end
+ test "admission locks the owning user before session and integration rows" do
+ for operation <- [:begin_turn, :append_invocation] do
+ parent = self()
+
+ {scope, integration, session, model, credential} =
+ Task.async(fn ->
+ Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn -> admission_fixture() end)
+ end)
+ |> Task.await()
+
+ on_exit(fn -> delete_external_user(scope.user.id) end)
+
+ deletion =
+ Task.async(fn ->
+ Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn ->
+ Repo.transaction(fn ->
+ Ecto.Adapters.SQL.query!(Repo, "SELECT id FROM users WHERE id = $1 FOR UPDATE", [
+ scope.user.id
+ ])
+
+ Ecto.Adapters.SQL.query!(
+ Repo,
+ "SELECT id FROM provider_integrations WHERE id = $1 FOR UPDATE",
+ [Ecto.UUID.dump!(integration.id)]
+ )
+
+ send(parent, {:admission_delete_ready, self()})
+
+ receive do
+ :delete_user -> Repo.delete!(scope.user)
+ end
+ end)
+ end)
+ end)
+
+ assert_receive {:admission_delete_ready, deletion_holder}
+
+ admission =
+ Task.async(fn ->
+ Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn ->
+ %{rows: [[backend_pid]]} = Ecto.Adapters.SQL.query!(Repo, "SELECT pg_backend_pid()")
+ send(parent, {:admission_backend, backend_pid})
+
+ case operation do
+ :begin_turn ->
+ Sessions.begin_turn(session.id, "Race account deletion")
+
+ :append_invocation ->
+ Sessions.append_admitted_model_event(
+ session.id,
+ "model_invocation_started",
+ %{"invocation_id" => Ecto.UUID.generate()},
+ model,
+ credential
+ )
+ end
+ end)
+ end)
+
+ assert_receive {:admission_backend, backend_pid}
+ assert_query_blocked!(backend_pid)
+ send(deletion_holder, :delete_user)
+
+ assert {:ok, _deleted} = Task.await(deletion)
+ assert {:error, :session_not_found} = Task.await(admission)
+ assert is_nil(Repo.get(Session, session.id))
+ end
+ end
+
test "atomically records invocation admission for the exact active credential generation", %{
runner: runner,
scope: scope
@@ -929,4 +1042,59 @@ defmodule Kodo.SessionsTest do
]
}
end
+
+ defp admission_fixture do
+ scope = user_scope_fixture()
+
+ {:ok, runner} =
+ Runners.register(scope, %{
+ workspace_root: "/work/#{Ecto.UUID.generate()}",
+ platform: "linux",
+ architecture: "x86_64",
+ runner_version: "0.1.0",
+ protocol_version: 5,
+ capabilities: []
+ })
+
+ {:ok, integration} =
+ Integrations.connect(scope, "openai", "api_key", %{"api_key" => "admission-lock-key"})
+
+ {:ok, model, reference} = LLM.resolve_integration(scope, "openai:gpt-4o-mini")
+ {:ok, credential} = LLM.admit_credential(scope, model, reference)
+
+ {:ok, session} =
+ Sessions.create_session(scope, %{
+ runner_id: runner.id,
+ title: "Admission deletion lock order",
+ model: "openai:gpt-4o-mini"
+ })
+
+ {scope, integration, session, model, credential}
+ end
+
+ defp delete_external_user(user_id) do
+ Task.async(fn ->
+ Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn -> delete_user_if_present(user_id) end)
+ end)
+ |> Task.await()
+ end
+
+ defp delete_user_if_present(user_id) do
+ if user = Repo.get(User, user_id), do: Repo.delete!(user)
+ end
+
+ defp assert_query_blocked!(backend_pid, attempts \\ 100)
+
+ defp assert_query_blocked!(_backend_pid, 0), do: flunk("query did not block")
+
+ defp assert_query_blocked!(backend_pid, attempts) do
+ %{rows: [[blocked?]]} =
+ Ecto.Adapters.SQL.query!(
+ Repo,
+ "SELECT cardinality(pg_blocking_pids($1)) > 0",
+ [backend_pid]
+ )
+
+ if blocked?, do: :ok, else: assert_query_blocked!(backend_pid, attempts - 1)
+ end
end
diff --git a/test/kodo_web/live/integrations_live_test.exs b/test/kodo_web/live/integrations_live_test.exs
index 2f5bcb4..e4df513 100644
--- a/test/kodo_web/live/integrations_live_test.exs
+++ b/test/kodo_web/live/integrations_live_test.exs
@@ -153,13 +153,23 @@ defmodule KodoWeb.IntegrationsLiveTest do
panel = "#integration-#{integration.id}-device-authorization"
assert has_element?(view, panel, "https://auth.openai.com/codex/device")
- assert has_element?(view, panel, "ABCD-EFGH")
+
+ assert has_element?(
+ view,
+ "#{panel} code.whitespace-nowrap",
+ "ABCD-EFGH"
+ )
assert has_element?(
view,
"#integration-#{integration.id}-copy-device-code[data-copy-text='ABCD-EFGH'][aria-label='Copy one-time code']"
)
+ assert has_element?(
+ view,
+ "#integration-#{integration.id}-copy-device-code-status[role='status'][aria-live='polite']:not(.sr-only)"
+ )
+
refute inspect(:sys.get_state(view.pid)) =~ "private-device"
render_click(view, "cancel_device_authorization", %{
@@ -252,6 +262,13 @@ defmodule KodoWeb.IntegrationsLiveTest do
{:ok, view, _html} = live(conn, ~p"/integrations")
refute has_element?(view, "#integration-#{integration.id}-device-authorization")
assert Repo.reload!(attempt).state == "expired"
+ _ = :sys.get_state(view.pid)
+
+ assert has_element?(
+ view,
+ "#integration-#{integration.id}-device-authorization-outcome[role='alert']",
+ "Authorization expired"
+ )
end
test "resumes an exchange-stage authorization after its worker lease expires", %{
@@ -398,7 +415,7 @@ defmodule KodoWeb.IntegrationsLiveTest do
assert live_assign(view, :device_authorization_retries) == %{}
end
- test "drops an old retry immediately when a new authorization supersedes it", %{
+ test "replaces an old retry when another tab supersedes an authorization", %{
conn: conn,
scope: scope
} do
@@ -438,7 +455,119 @@ defmodule KodoWeb.IntegrationsLiveTest do
_ = :sys.get_state(view.pid)
assert second.attempt_generation > first.attempt_generation
- assert live_assign(view, :device_authorization_retries) == %{}
+
+ retries = live_assign(view, :device_authorization_retries)
+ refute Map.has_key?(retries, retry_key)
+ assert Map.has_key?(retries, {integration.id, second.attempt_generation})
+
+ send(
+ view.pid,
+ {:device_authorization_changed, integration.id, first.attempt_generation, "failed",
+ "provider_rejected"}
+ )
+
+ _ = :sys.get_state(view.pid)
+ refute has_element?(view, "#integration-#{integration.id}-device-authorization-outcome")
+
+ assert {:ok, {claim, _payload}} =
+ Integrations.claim_device_authorization(
+ scope,
+ integration.id,
+ Ecto.UUID.generate()
+ )
+
+ assert {:ok, _completed} =
+ Integrations.complete_device_authorization(
+ scope,
+ claim,
+ %{
+ "access_token" => "new-access",
+ "refresh_token" => "new-refresh",
+ "id_token" => "new-identity",
+ "account_id" => "new-account"
+ },
+ DateTime.add(DateTime.utc_now(), 3_600, :second)
+ )
+
+ send(
+ view.pid,
+ {:device_authorization_changed, integration.id, first.attempt_generation, "active", nil}
+ )
+
+ send(
+ view.pid,
+ {:device_authorization_changed, integration.id, first.attempt_generation, "failed",
+ "provider_rejected"}
+ )
+
+ _ = :sys.get_state(view.pid)
+ refute has_element?(view, "#integration-#{integration.id}-device-authorization-outcome")
+ end
+
+ test "shows failed authorization feedback on the affected account", %{
+ conn: conn,
+ scope: scope
+ } do
+ assert {:ok, integration} =
+ Integrations.create_oauth_integration(scope, "openai_codex",
+ display_name: "Failed subscription"
+ )
+
+ assert {:ok, first} =
+ Integrations.begin_device_authorization(
+ scope,
+ integration.id,
+ integration.credential_generation,
+ %{"device_auth_id" => "old-device", "user_code" => "OLD"},
+ 30_000
+ )
+
+ assert {:ok, second} =
+ Integrations.begin_device_authorization(
+ scope,
+ integration.id,
+ first.expected_integration_generation,
+ %{"device_auth_id" => "failed-device", "user_code" => "FAILED"},
+ 30_000
+ )
+
+ assert {:ok, {claim, _payload}} =
+ Integrations.claim_device_authorization(
+ scope,
+ integration.id,
+ Ecto.UUID.generate()
+ )
+
+ {:ok, view, _html} = live(conn, ~p"/integrations")
+
+ assert {:ok, _failed} =
+ Integrations.fail_device_authorization(scope, claim, "provider_rejected")
+
+ send(
+ view.pid,
+ {:device_authorization_changed, integration.id, first.attempt_generation, "active", nil}
+ )
+
+ send(
+ view.pid,
+ {:device_authorization_changed, integration.id, first.attempt_generation, "failed",
+ "provider_rejected"}
+ )
+
+ _ = :sys.get_state(view.pid)
+
+ assert %{attempt_generation: generation} =
+ live_assign(view, :device_authorization_outcomes)[integration.id]
+
+ assert generation == second.attempt_generation
+
+ assert has_element?(
+ view,
+ "#integration-#{integration.id}-device-authorization-outcome[role='alert']",
+ "OpenAI could not complete authorization"
+ )
+
+ refute has_element?(view, "#integration-#{integration.id}-device-authorization")
end
test "updates an open authorization panel when polling advances to exchange", %{