Skip to content

Commit 4e915a6

Browse files
committed
Add AppKit governed effect surface
1 parent 3f4fae2 commit 4e915a6

18 files changed

Lines changed: 1102 additions & 3 deletions

File tree

bridges/mezzanine_bridge/lib/app_kit/bridges/mezzanine_bridge.ex

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ defmodule AppKit.Bridges.MezzanineBridge do
99

1010
alias AppKit.Bridges.MezzanineBridge.{
1111
AgentIntakeAdapter,
12+
EffectAdapter,
1213
HeadlessAdapter,
1314
InstallationAdapter,
1415
OperatorAdapter,
@@ -28,6 +29,7 @@ defmodule AppKit.Bridges.MezzanineBridge do
2829
@behaviour AppKit.Core.Backends.HeadlessBackend
2930
@behaviour AppKit.Core.Backends.AgentIntakeBackend
3031
@behaviour AppKit.Core.Backends.RuntimeBackend
32+
@behaviour AppKit.EffectSurface
3133

3234
alias AppKit.Core.{
3335
AuthoringBundleImport,
@@ -468,4 +470,24 @@ defmodule AppKit.Bridges.MezzanineBridge do
468470
def await_agent_outcome(%RequestContext{} = context, run_ref, request, opts) do
469471
AgentIntakeAdapter.await_agent_outcome(context, run_ref, request, opts)
470472
end
473+
474+
@impl AppKit.EffectSurface
475+
def propose_effect(%RequestContext{} = context, effect_params, opts) do
476+
EffectAdapter.propose_effect(context, effect_params, opts)
477+
end
478+
479+
@impl AppKit.EffectSurface
480+
def get_effect(%RequestContext{} = context, effect_ref, opts) do
481+
EffectAdapter.get_effect(context, effect_ref, opts)
482+
end
483+
484+
@impl AppKit.EffectSurface
485+
def list_effects(%RequestContext{} = context, run_ref, opts) do
486+
EffectAdapter.list_effects(context, run_ref, opts)
487+
end
488+
489+
@impl AppKit.EffectSurface
490+
def get_effect_timeline(%RequestContext{} = context, effect_ref, opts) do
491+
EffectAdapter.get_effect_timeline(context, effect_ref, opts)
492+
end
471493
end
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
defmodule AppKit.Bridges.MezzanineBridge.EffectAdapter do
2+
@moduledoc false
3+
4+
@behaviour AppKit.EffectSurface
5+
6+
alias AppKit.Bridges.MezzanineBridge.{Errors, Services}
7+
8+
alias AppKit.Core.{
9+
EffectTimelineDTO,
10+
GovernedEffectDTO,
11+
RequestContext
12+
}
13+
14+
alias Mezzanine.Core.GovernedEffects.Coordinator
15+
alias Mezzanine.Core.GovernedEffects.Coordinator.Run
16+
alias Mezzanine.Core.GovernedEffects.Projection
17+
18+
@impl true
19+
def propose_effect(%RequestContext{}, %GovernedEffectDTO{} = effect, opts)
20+
when is_list(opts) do
21+
effect
22+
|> GovernedEffectDTO.dump()
23+
|> propose_effect_from_attrs(opts)
24+
end
25+
26+
def propose_effect(%RequestContext{}, effect_params, opts)
27+
when is_map(effect_params) and is_list(opts) do
28+
propose_effect_from_attrs(effect_params, opts)
29+
end
30+
31+
@impl true
32+
def get_effect(%RequestContext{}, effect_ref, opts)
33+
when is_binary(effect_ref) and is_list(opts) do
34+
case Services.effect_readback(opts).get_effect(effect_ref, opts) do
35+
{:ok, %Run{} = run} -> dto_from_run(run)
36+
{:error, reason} -> Errors.normalize(reason)
37+
end
38+
end
39+
40+
@impl true
41+
def list_effects(%RequestContext{}, run_ref, opts) when is_binary(run_ref) and is_list(opts) do
42+
case Services.effect_readback(opts).list_effects(run_ref, opts) do
43+
{:ok, runs} -> collect_dtos(runs)
44+
{:error, reason} -> Errors.normalize(reason)
45+
end
46+
end
47+
48+
@impl true
49+
def get_effect_timeline(%RequestContext{}, effect_ref, opts)
50+
when is_binary(effect_ref) and is_list(opts) do
51+
with {:ok, %Run{} = run} <- Services.effect_readback(opts).get_effect(effect_ref, opts),
52+
projection <- Projection.product_safe(run) do
53+
EffectTimelineDTO.new(%{
54+
effect_ref: effect_ref,
55+
trace_summary_hash: Map.get(projection, "trace_summary_hash"),
56+
entries: Map.get(projection, "timeline", [])
57+
})
58+
else
59+
{:error, reason} -> Errors.normalize(reason)
60+
end
61+
end
62+
63+
defp propose_effect_from_attrs(attrs, opts) do
64+
case Coordinator.propose(attrs, opts) do
65+
{:ok, run} -> dto_from_run(run)
66+
{:error, reason} -> Errors.normalize(reason)
67+
end
68+
end
69+
70+
defp collect_dtos(runs) do
71+
Enum.reduce_while(runs, {:ok, []}, fn
72+
%Run{} = run, {:ok, acc} ->
73+
case dto_from_run(run) do
74+
{:ok, dto} -> {:cont, {:ok, [dto | acc]}}
75+
{:error, reason} -> {:halt, {:error, reason}}
76+
end
77+
78+
_other, _acc ->
79+
{:halt, Errors.normalize(:invalid_effect_projection)}
80+
end)
81+
|> case do
82+
{:ok, dtos} -> {:ok, Enum.reverse(dtos)}
83+
{:error, reason} -> {:error, reason}
84+
end
85+
end
86+
87+
defp dto_from_run(%Run{} = run) do
88+
projection = Projection.product_safe(run)
89+
90+
GovernedEffectDTO.new(%{
91+
effect_ref: run.effect.effect_ref,
92+
effect_type: run.effect.effect_type,
93+
command_ref: command_value(run.command, :command_ref),
94+
tenant_ref: run.effect.tenant_ref,
95+
actor_ref: run.effect.actor_ref,
96+
installation_ref: run.effect.installation_ref,
97+
status: Map.get(projection, "status"),
98+
trace_ref: run.effect.trace_ref,
99+
authority_ref: run.effect.authority_ref,
100+
receipt_ref: run.effect.receipt_ref,
101+
dispatch_ref: run.effect.dispatch_ref,
102+
expected_version: run.effect.expected_version,
103+
metadata: %{
104+
"trace_summary_hash" => Map.get(projection, "trace_summary_hash")
105+
}
106+
})
107+
end
108+
109+
defp command_value(command, key), do: Map.get(command, key, Map.get(command, to_string(key)))
110+
end

bridges/mezzanine_bridge/lib/app_kit/bridges/mezzanine_bridge/runtime_adapter.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ defmodule AppKit.Bridges.MezzanineBridge.RuntimeAdapter do
4646
accepted?: true,
4747
command_ref: "command://#{agent_request.idempotency_key}",
4848
correlation_id: agent_request.correlation_id,
49+
governed_effect_refs: RuntimeMapping.governed_effect_refs(projection, agent_request),
4950
polling_hint: %{checking?: false, poll_interval_ms: 1_000, staleness_ms: 0}
5051
}) do
5152
{:ok, future}

bridges/mezzanine_bridge/lib/app_kit/bridges/mezzanine_bridge/runtime_mapping.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ defmodule AppKit.Bridges.MezzanineBridge.RuntimeMapping do
6161
max_turns: param(params, :max_turns, 1),
6262
timeout_policy: timeout_policy(params),
6363
profile_bundle: Map.from_struct(profile_bundle),
64+
effect_governance_mode: request.effect_governance_mode,
65+
diagnostic_lane: request.diagnostic_lane,
66+
governed_effect_refs: request.governed_effect_refs,
6467
fixture_script: param(params, :fixture_script, "success_first_try"),
6568
continue_as_new_turn_threshold: param(params, :continue_as_new_turn_threshold, 50),
6669
source_ref: "actor://#{context.actor_ref.id}"
@@ -120,6 +123,13 @@ defmodule AppKit.Bridges.MezzanineBridge.RuntimeMapping do
120123
:session_turn
121124
end
122125

126+
def governed_effect_refs(projection, %AgentRunRequest{} = request) do
127+
case Common.fetch_value(projection, :governed_effect_refs) do
128+
refs when is_map(refs) and map_size(refs) > 0 -> refs
129+
_missing -> request.governed_effect_refs || %{}
130+
end
131+
end
132+
123133
def runtime_available?(runtime) when is_atom(runtime),
124134
do: Code.ensure_loaded?(runtime) and function_exported?(runtime, :run, 1)
125135

bridges/mezzanine_bridge/lib/app_kit/bridges/mezzanine_bridge/services.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ defmodule AppKit.Bridges.MezzanineBridge.Services do
2525
def runtime_gateway(opts),
2626
do: Keyword.get(opts, :runtime_gateway_service, Mezzanine.AppKitBridge.RuntimeGatewayService)
2727

28+
def effect_readback(opts),
29+
do: Keyword.get(opts, :effect_readback_service, Mezzanine.AppKitBridge.EffectReadbackService)
30+
2831
def program_context(opts),
2932
do: Keyword.get(opts, :program_context_service, Mezzanine.AppKitBridge.ProgramContextService)
3033

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
defmodule Mezzanine.AppKitBridge.EffectReadbackService do
2+
@moduledoc false
3+
4+
alias Mezzanine.Core.GovernedEffects.Coordinator.Run
5+
6+
@spec get_effect(String.t(), keyword()) :: {:ok, Run.t()} | {:error, term()}
7+
def get_effect(effect_ref, opts) when is_binary(effect_ref) and is_list(opts) do
8+
case run_for(effect_ref, opts) do
9+
%Run{} = run -> {:ok, run}
10+
nil -> {:error, :effect_projection_not_configured}
11+
end
12+
end
13+
14+
@spec list_effects(String.t(), keyword()) :: {:ok, [Run.t()]} | {:error, term()}
15+
def list_effects(_run_ref, opts) when is_list(opts) do
16+
runs =
17+
opts
18+
|> Keyword.get(:effect_runs, %{})
19+
|> Map.values()
20+
21+
{:ok, Enum.filter(runs, &match?(%Run{}, &1))}
22+
end
23+
24+
defp run_for(effect_ref, opts) do
25+
case Keyword.get(opts, :effect_runs, %{}) do
26+
runs when is_map(runs) -> Map.get(runs, effect_ref)
27+
_other -> nil
28+
end
29+
end
30+
end

bridges/mezzanine_bridge/mix.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ defmodule AppKitMezzanineBridge.MixProject do
7070
DependencySources.dep(:mezzanine_projection_engine, @repo_root),
7171
DependencySources.dep(:mezzanine_source_engine, @repo_root),
7272
DependencySources.dep(:mezzanine_decision_engine, @repo_root),
73+
DependencySources.dep(:mezzanine_governed_effects, @repo_root),
7374
DependencySources.dep(:mezzanine_evidence_engine, @repo_root),
7475
DependencySources.dep(:mezzanine_archival_engine, @repo_root),
7576
DependencySources.dep(:mezzanine_config_registry, @repo_root),

bridges/mezzanine_bridge/test/app_kit/bridges/mezzanine_bridge_contract_characterization_test.exs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,17 @@ defmodule AppKit.Bridges.MezzanineBridgeContractCharacterizationTest do
176176
cancel_agent_run: 3,
177177
await_agent_outcome: 4
178178
]
179+
},
180+
%{
181+
behaviour: AppKit.EffectSurface,
182+
target_module: "AppKit.Bridges.MezzanineBridge.EffectAdapter",
183+
service_options: [:effect_readback],
184+
callbacks: [
185+
propose_effect: 3,
186+
get_effect: 3,
187+
list_effects: 3,
188+
get_effect_timeline: 3
189+
]
179190
}
180191
]
181192

@@ -740,7 +751,8 @@ defmodule AppKit.Bridges.MezzanineBridgeContractCharacterizationTest do
740751
"AppKit.Bridges.MezzanineBridge.OperatorAdapter",
741752
"AppKit.Bridges.MezzanineBridge.RuntimeAdapter",
742753
"AppKit.Bridges.MezzanineBridge.HeadlessAdapter",
743-
"AppKit.Bridges.MezzanineBridge.AgentIntakeAdapter"
754+
"AppKit.Bridges.MezzanineBridge.AgentIntakeAdapter",
755+
"AppKit.Bridges.MezzanineBridge.EffectAdapter"
744756
]
745757

746758
for adapter <- @adapter_contract, {callback, arity} <- adapter.callbacks do
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
defmodule AppKit.Bridges.MezzanineBridgeEffectAdapterTest do
2+
use ExUnit.Case, async: true
3+
4+
alias AppKit.Bridges.MezzanineBridge.EffectAdapter
5+
alias AppKit.Core.{EffectTimelineDTO, GovernedEffectDTO, RequestContext}
6+
7+
test "effect adapter delegates proposal to Mezzanine governed-effect coordinator" do
8+
assert {:ok, %GovernedEffectDTO{} = dto} =
9+
EffectAdapter.propose_effect(context!(), effect_attrs(), [])
10+
11+
assert dto.effect_ref == "effect://tenant-1/effects/1"
12+
assert dto.status == "proposed"
13+
assert dto.trace_ref == "trace://tenant-1/effects/1"
14+
end
15+
16+
test "effect adapter readback delegates projection through Mezzanine run data" do
17+
assert {:ok, run} =
18+
Mezzanine.Core.GovernedEffects.Coordinator.propose(effect_attrs())
19+
20+
opts = [effect_runs: %{run.effect.effect_ref => run}]
21+
22+
assert {:ok, %GovernedEffectDTO{effect_ref: "effect://tenant-1/effects/1"}} =
23+
EffectAdapter.get_effect(context!(), "effect://tenant-1/effects/1", opts)
24+
25+
assert {:ok, [%GovernedEffectDTO{effect_ref: "effect://tenant-1/effects/1"}]} =
26+
EffectAdapter.list_effects(context!(), "run://tenant-1/runs/1", opts)
27+
28+
assert {:ok, %EffectTimelineDTO{entries: [%{"event_kind" => "effect_transition"}]}} =
29+
EffectAdapter.get_effect_timeline(context!(), "effect://tenant-1/effects/1", opts)
30+
end
31+
32+
defp context! do
33+
{:ok, context} =
34+
RequestContext.new(%{
35+
trace_id: "0123456789abcdef0123456789abcdef",
36+
actor_ref: %{
37+
id: "actor://tenant-1/operator",
38+
kind: "operator",
39+
roles: ["developer"]
40+
},
41+
tenant_ref: %{id: "tenant://tenant-1", slug: "tenant-1"},
42+
installation_ref: %{
43+
id: "installation://tenant-1/synapse",
44+
pack_slug: "synapse"
45+
},
46+
request_id: "request-1",
47+
idempotency_key: "idempotency://tenant-1/request-1"
48+
})
49+
50+
context
51+
end
52+
53+
defp effect_attrs do
54+
%{
55+
effect_ref: "effect://tenant-1/effects/1",
56+
effect_type: "diagnostic.echo",
57+
command_ref: "command://tenant-1/commands/1",
58+
tenant_ref: "tenant://tenant-1",
59+
actor_ref: "actor://tenant-1/operator",
60+
installation_ref: "installation://tenant-1/synapse",
61+
trace_ref: "trace://tenant-1/effects/1",
62+
expected_version: 1,
63+
payload: %{"message_ref" => "payload://tenant-1/messages/1"}
64+
}
65+
end
66+
end

build_support/dependency_sources.config.exs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@
1111
default_order: [:path, :github, :hex],
1212
publish_order: [:hex]
1313
},
14+
mezzanine_governed_effects: %{
15+
path: "../mezzanine/core/governed_effects",
16+
hex: "~> 0.1.0",
17+
github: %{
18+
repo: "nshkrdotcom/mezzanine",
19+
subdir: "core/governed_effects",
20+
branch: "main"
21+
},
22+
default_order: [:path, :github, :hex],
23+
publish_order: [:hex]
24+
},
1425
mezzanine_projection_engine: %{
1526
path: "../mezzanine/core/projection_engine",
1627
hex: "~> 0.1.0",

0 commit comments

Comments
 (0)