Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,8 @@ function sanitizeInitialForm({
readBindings,
writeBindings,
}: WorkbenchFragment): WorkbenchFormState {
const { infrastructure, coding, observability } = configuration ?? {}
const { infrastructure, coding, observability, selfService } =
configuration ?? {}
const { kubernetes, services, stacks, podLogs, vulnerabilities } =
infrastructure ?? {}
const { logs, metrics } = observability ?? {}
Expand Down Expand Up @@ -628,6 +629,7 @@ function sanitizeInitialForm({
repositoryId: repository?.id ?? null,
overrideBotUser: false,
configuration: {
selfService: selfService ?? false,
infrastructure: {
kubernetes,
services,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export function WorkbenchSetupStep({
}: WorkbenchFormStepProps) {
const theme = useTheme()
const update = createFormUpdater(setFormState)
const selfService = formState.configuration?.selfService
const infra = formState.configuration?.infrastructure
const observability = formState.configuration?.observability
const capabilityCheckboxGridCss = {
Expand Down Expand Up @@ -172,6 +173,31 @@ export function WorkbenchSetupStep({
direction="column"
gap="large"
>
<FormField label="Enable Self-Service">
<Flex
direction="column"
gap="small"
>
<CaptionP $color="text-light">
Enable Plural catalog and PR automation workflows for repeatable
GitOps provisioning. Prefer this for clear golden paths; undefined
or custom code changes still go through the coding agent.
</CaptionP>
<Flex css={capabilityCheckboxGridCss}>
<CapabilityCheckbox
label="Self-Service"
checked={selfService ?? false}
tooltip="Expose a self-service subagent that can list catalogs, search and inspect PR automations, and invoke them while associating generated PRs with this workbench job."
onCheckedChange={(checked) =>
update((d) => {
d.configuration ??= {}
d.configuration.selfService = checked
})
}
/>
</Flex>
</Flex>
</FormField>
<FormField label="Enable Infrastructure">
<Flex
direction="column"
Expand Down
15 changes: 11 additions & 4 deletions js/console/src/generated/graphql.ts

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions js/console/src/generated/persisted-queries/client.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions js/console/src/graph/workbench.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ fragment Workbench on Workbench {
id
}
configuration {
selfService
infrastructure {
services
stacks
Expand Down
91 changes: 91 additions & 0 deletions lib/console/ai/tools/workbench/self_service/catalog_search.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
defmodule Console.AI.Tools.Workbench.SelfService.CatalogSearch do
use Console.AI.Tools.Workbench.Base
import Ecto.Query
alias Console.Repo
alias Console.AI.Tool
alias Console.Deployments.{Git, Policies}
alias Console.Schema.{Catalog, PrAutomation}

embedded_schema do

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all queries in this function should have a limit (maybe 100)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put it at 100, which means fallback_search could query up to 200 results

field :query, :string
end

@valid ~w(query)a
@json_schema Console.priv_file!("tools/workbench/self_service/catalog_search.json") |> Jason.decode!()

def json_schema(), do: @json_schema
def name(), do: "workbench_catalog_search"
def description(), do: """
Search Plural catalogs and PR automations that are available to the current user.
Prefer this when you need a relevant golden path but do not yet know which catalog or automation fits.
Falls back to name search when semantic search is unavailable.
"""

def changeset(model, attrs) do
model
|> cast(attrs, @valid)
|> validate_required([:query])
end

def implement(%__MODULE__{query: query}) do
with {:actor, %{} = user} <- {:actor, Tool.actor()},
{:search, ^user, {:ok, results}} <- {:search, user, Git.catalog_search(query, user: user, count: 100)} do
format_results(results)
else
{:actor, _} ->
{:ok, "not logged in"}
{:search, user, {:error, _}} ->
fallback_search(query, user)
end
end

defp fallback_search(query, user) do
catalog_hits(query, user)
|> Enum.concat(automation_hits(query, user))
|> Jason.encode()
end

defp catalog_hits(query, user) do
Catalog.search(query)
|> Catalog.for_user(user)
|> limit(100)
|> Repo.all()
|> Enum.map(&%{catalog: Map.take(&1, [:id, :name, :description, :category])})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ultimately not a huge deal here, but Enum.map materializes a new list (alongside really all enum mod functions). For something that could be large enumerations, Stream equivalents is lazy and more memory efficient

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Questions (still learning Elixir):

  1. haven't the lists been loaded from the DB already?
  2. aren't we definitely going to wind up creating an enum list (in fallback_search) if this function gets called?
  3. do you still want the map now that we have the limit?

I'm happy to switch to the Stream whether for best practice or if it's actually better in this case. Just using this opportunity to make sure I understand what's happening.

end

defp automation_hits(query, user) do
PrAutomation.search(query)
|> limit(100)
|> Repo.all()
|> Repo.preload([:catalog])
|> Enum.filter(&readable?(&1, user))
|> Enum.map(fn pra ->
%{
pr_automation: Map.take(pra, [:id, :name, :documentation, :title, :branch])
|> Map.put(:description, pra.documentation)
|> Map.put(:catalog, pra.catalog && Map.take(pra.catalog, [:id, :name]))
}
end)
end

defp readable?(%PrAutomation{catalog: %Catalog{} = catalog}, user),
do: match?({:ok, _}, Policies.allow(catalog, user, :read))
defp readable?(%PrAutomation{} = pra, user),
do: match?({:ok, _}, Policies.allow(pra, user, :create))

defp format_results(results) do
Enum.map(results, fn
%{catalog: %Catalog{} = catalog} ->
%{catalog: Map.take(catalog, [:id, :name, :description, :category])}
%{pr_automation: %PrAutomation{} = pra} ->
%{
pr_automation:
Map.take(pra, [:id, :name, :documentation, :title, :branch])
|> Map.put(:description, pra.documentation)
}
other ->
other
end)
|> Jason.encode()
end
end
75 changes: 75 additions & 0 deletions lib/console/ai/tools/workbench/self_service/get_pr_automation.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
defmodule Console.AI.Tools.Workbench.SelfService.GetPrAutomation do
use Console.AI.Tools.Workbench.Base
alias Console.Repo
alias Console.AI.Tool
alias Console.Deployments.{Git, Policies}
alias Console.Schema.PrAutomation

embedded_schema do
field :pr_automation_id, :string
field :name, :string
end

@valid ~w(pr_automation_id name)a
@json_schema Console.priv_file!("tools/workbench/self_service/get_pr_automation.json") |> Jason.decode!()
@fields ~w(id name documentation title message branch branch_prefix identifier configuration icon dark_icon)a

def json_schema(), do: @json_schema
def name(), do: "workbench_get_pr_automation"
def description(), do: """
Fetch a single PR automation by id or name, including documentation, branch metadata,
configuration fields, and confirmation requirements. Use this before invoking an automation
so you can fill a valid context.
"""

def changeset(model, attrs) do
model
|> cast(attrs, @valid)
|> validate_one_of()
end

defp validate_one_of(cs) do
case {get_field(cs, :pr_automation_id), get_field(cs, :name)} do
{id, _} when is_binary(id) and byte_size(id) > 0 -> cs
{_, name} when is_binary(name) and byte_size(name) > 0 -> cs
_ -> add_error(cs, :pr_automation_id, "either pr_automation_id or name is required")
end
end

def implement(%__MODULE__{} = model) do
with %{} = user <- Tool.actor(),
%PrAutomation{} = pra <- fetch(model),
{:ok, _} <- Policies.allow(pra, user, :read) do
pra
|> Repo.preload([:catalog])
|> format()
|> Jason.encode()
else
nil -> {:ok, "PR automation not found"}
{:error, _} -> {:ok, "You do not have access to this PR automation"}
_ -> {:ok, "not logged in"}
end
end

defp fetch(%__MODULE__{pr_automation_id: id}) when is_binary(id) and byte_size(id) > 0,
do: Git.get_pr_automation(id)
defp fetch(%__MODULE__{name: name}) when is_binary(name) and byte_size(name) > 0,
do: Git.get_pr_automation_by_name(name)
defp fetch(_), do: nil

defp format(%PrAutomation{} = pra) do
Map.take(pra, @fields)
|> Map.put(:description, pra.documentation)
|> Map.put(:catalog, format_catalog(pra.catalog))
|> Map.put(:confirmation, format_confirmation(pra.confirmation))
|> Console.mapify()
end

defp format_catalog(%{id: id, name: name, description: description, category: category}),
do: %{id: id, name: name, description: description, category: category}
defp format_catalog(_), do: nil

defp format_confirmation(%{text: text, checklist: checklist}),
do: %{text: text, checklist: Enum.map(checklist || [], &Map.take(&1, [:label]))}
defp format_confirmation(_), do: nil
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
defmodule Console.AI.Tools.Workbench.SelfService.InvokePrAutomation do
use Console.AI.Tools.Workbench.Base
alias Console.AI.Tool
alias Console.Deployments.Git
alias Console.Schema.{WorkbenchJob, PullRequest}

embedded_schema do
field :pr_automation_id, :string
field :context, :string
field :branch, :string
field :identifier, :string
field :job, :map, virtual: true
end

@valid ~w(pr_automation_id context branch identifier)a
@json_schema Console.priv_file!("tools/workbench/self_service/invoke_pr_automation.json") |> Jason.decode!()

def json_schema(_), do: @json_schema
def name(_), do: "workbench_invoke_pr_automation"
def description(_), do: """
Invoke a PR automation to create a pull request for a clear GitOps provisioning pathway.
The generated pull request is automatically associated with the current workbench job.
Call this only after confirming a relevant automation and filling a valid context. Prefer a single invocation.
"""

def changeset(model, attrs) do
model
|> cast(attrs, @valid)
|> validate_required([:pr_automation_id, :branch])
end

def implement(%__MODULE__{pr_automation_id: pra_id, branch: branch} = model) do
with %{} = user <- Tool.actor(),
%WorkbenchJob{id: job_id, workbench_id: workbench_id} <- job(model),
{:ok, %PullRequest{} = pr} <-
Git.create_pull_request(
%{workbench_job_id: job_id, workbench_id: workbench_id},
get_context(model),
pra_id,
branch,
model.identifier,
user
) do
Jason.encode(%{
id: pr.id,
url: pr.url,
title: pr.title,
status: pr.status,
Comment thread
michaeljguarino marked this conversation as resolved.
workbench_job_id: pr.workbench_job_id,
workbench_id: pr.workbench_id
})
else
nil -> {:ok, "no workbench job or user available for this invocation"}
{:error, %Ecto.Changeset{} = cs} ->
{:ok, "failed to create pull request: #{inspect(Console.GraphQl.Helpers.resolve_changeset(cs))}"}
{:error, err} when is_binary(err) -> {:ok, "failed to create pull request: #{err}"}
{:error, err} -> {:ok, "failed to create pull request: #{inspect(err)}"}
err -> {:ok, "failed to create pull request: #{inspect(err)}"}
end
end

defp job(%__MODULE__{job: %WorkbenchJob{} = job}), do: job
defp job(_) do
case Tool.context() do
%{job: %WorkbenchJob{} = job} -> job
_ -> nil
end
end

defp get_context(%__MODULE__{context: ctx}) when is_binary(ctx) do
case Jason.decode(ctx) do
{:ok, %{} = map} -> map
_ -> %{}
end
end
defp get_context(_), do: %{}
end
3 changes: 2 additions & 1 deletion lib/console/ai/tools/workbench/subagent.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ defmodule Console.AI.Tools.Workbench.Subagent do
history: 5,
search: 6,
verify: 7,
monitoring: 8
monitoring: 8,
self_service: 9

embedded_schema do
field :subagents, {:array, Subagent}, virtual: true
Expand Down
1 change: 1 addition & 0 deletions lib/console/ai/tools/workbench/subagents.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ defmodule Console.AI.Tools.Workbench.Subagents do
defp subagent_description(_, :history, _, _), do: "Invoke a history subagent to search past workbench activities. Useful to remember what has been done so far, with regex support for finding past work."
defp subagent_description(_, :search, _, _), do: "Invoke a web search subagent to search the public web for information. Useful to find documentation, public pricing information, and anything else that's not specific to deployed infrastructure."
defp subagent_description(_, :verify, _, _), do: "Invoke a verification subagent to verify the job was successfully completed based on infrastructure and observability state."
defp subagent_description(_, :self_service, _, _), do: "Invoke a self-service subagent to discover Plural catalogs and PR automations, then invoke a clear GitOps provisioning pathway. Prefer this for repeatable golden-path provisioning; punt undefined or custom code work to the coding subagent."
defp subagent_description(_, _, _, _), do: "Unknown subagent"

defp infra_description(%{vulnerabilities: vulns, pod_logs: logs}) when vulns or logs do
Expand Down
3 changes: 2 additions & 1 deletion lib/console/ai/workbench/engine.ex
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ defmodule Console.AI.Workbench.Engine do
defp action_call_id(%{id: %Console.AI.Tool{id: id}}), do: id
defp action_call_id(_), do: nil

@supported_subagents ~w(infrastructure integration coding observability monitoring memory skill history search verify)a
@supported_subagents ~w(infrastructure integration coding observability monitoring memory skill history search verify self_service)a

defp spawn_activity(action, %__MODULE__{job: job} = engine) do
Tracking.with_activity(action, job, fn ->
Expand Down Expand Up @@ -453,6 +453,7 @@ defmodule Console.AI.Workbench.Engine do
defp subagent_module(:skill), do: SA.Skill
defp subagent_module(:search), do: SA.Search
defp subagent_module(:verify), do: SA.Verify
defp subagent_module(:self_service), do: SA.SelfService

defp tool_attrs(%{id: %Console.AI.Tool{id: id, name: name, arguments: arguments}}) when is_binary(id) and is_binary(name),
do: %{call_id: id, name: name, arguments: arguments}
Expand Down
4 changes: 4 additions & 0 deletions lib/console/ai/workbench/environment.ex
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ defmodule Console.AI.Workbench.Environment do
|> Enum.concat(type_subagents(job))
|> Enum.concat(coding_agents(bench))
|> Enum.concat(infra_agents(bench))
|> Enum.concat(self_service_agents(bench))
|> Enum.filter(&allow_subagent?(job, &1))
end

Expand Down Expand Up @@ -210,6 +211,9 @@ defmodule Console.AI.Workbench.Environment do
end
defp infra_agents(_), do: []

defp self_service_agents(%Workbench{configuration: %{self_service: true}}), do: [:self_service]
defp self_service_agents(_), do: []

defp type_subagents(%WorkbenchJob{type: :skill}), do: [:history, :skill]
defp type_subagents(_), do: [:monitoring]

Expand Down
Loading
Loading