Skip to content
Merged
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
127 changes: 124 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,131 @@ jobs:
elixir_versions: '["1.18"]'
test_command: mix jido_shell.guardrails

changelog-guard:
name: Changelog Guard
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request' }}
permissions:
pull-requests: read
contents: read
steps:
- name: Check for CHANGELOG.md modifications
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
FILES=$(gh api --paginate repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files --jq '.[].filename')
if echo "$FILES" | grep -qE '^CHANGELOG\.md$'; then
echo "::error::CHANGELOG.md should not be edited manually - it is auto-generated by git_ops during releases."
echo ""
echo "Please remove CHANGELOG.md from your PR. Your changes will appear in the changelog"
echo "automatically based on your conventional commit messages:"
echo " - feat: commits create 'Added' entries"
echo " - fix: commits create 'Fixed' entries"
echo " - docs:, chore:, ci: commits are excluded"
exit 1
fi
echo "No CHANGELOG.md modifications detected"

lint:
name: Lint
uses: agentjido/github-actions/.github/workflows/elixir-lint.yml@main
secrets: inherit
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Elixir
uses: erlef/setup-beam@v1
with:
otp-version: "28"
elixir-version: "1.19"

- name: Install Hex/Rebar
run: |
set -euo pipefail
mix local.hex --force
mix local.rebar --force

- name: Restore Hex/Mix cache
uses: actions/cache@v4
with:
path: |
~/.hex
~/.mix
key: ${{ runner.os }}-beamtools-${{ hashFiles('mix.lock') }}
restore-keys: |
${{ runner.os }}-beamtools-

- name: Restore dependencies cache
uses: actions/cache@v4
id: deps-cache
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-dev-otp28-elixir1.19-${{ hashFiles('mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-dev-otp28-elixir1.19-

- name: Remove cached rebar3 build artifacts in deps
if: steps.deps-cache.outputs.cache-hit != 'true'
run: |
if [ -d deps ]; then
find deps -maxdepth 6 -type d \( -name _build -o -name .rebar3 \) -prune -exec rm -rf {} + || true
find deps -maxdepth 6 -type f -name rebar3.crashdump -delete || true
fi

- name: Install dependencies
run: mix deps.get

- name: Compile dependencies
if: steps.deps-cache.outputs.cache-hit != 'true'
run: mix deps.compile
env:
MIX_ENV: dev

- name: Create PLT directory
run: mkdir -p priv/plts

- name: Restore Dialyzer PLT cache
uses: actions/cache@v4
id: plt-cache
with:
path: priv/plts
key: ${{ runner.os }}-plt-otp28-elixir1.19-${{ hashFiles('mix.lock', 'mix.exs') }}
restore-keys: |
${{ runner.os }}-plt-otp28-elixir1.19-

- name: Run lint checks
run: mix quality
env:
MIX_ENV: dev

- name: Check for unused dependencies
run: mix deps.unlock --check-unused

- name: Audit Hex dependencies
run: mix hex.audit

- name: Detect git dependencies
id: git_deps
run: |
if grep -Eq '^\s*\{:[^,]+,\s*(github:|git:)' mix.exs; then
echo "has_git_deps=true" >> "$GITHUB_OUTPUT"
echo "Git dependencies detected in mix.exs; skipping Hex package dry run."
else
echo "has_git_deps=false" >> "$GITHUB_OUTPUT"
fi

- name: Validate Hex package (dry run)
if: ${{ github.event_name == 'pull_request' && steps.git_deps.outputs.has_git_deps != 'true' }}
run: mix hex.publish --dry-run
env:
MIX_ENV: dev
HEX_API_KEY: ${{ secrets.HEX_API_KEY }}

test:
name: Test
Expand All @@ -40,4 +161,4 @@ jobs:
elixir_versions: '["1.18"]'
test_command: >-
mix coveralls.json &&
MIX_ENV=test mix run -e 'data = Jason.decode!(File.read!("cover/excoveralls.json")); coverages = data["source_files"] |> Enum.flat_map(& &1["coverage"]); relevant = Enum.count(coverages, &(!is_nil(&1))); missed = Enum.count(coverages, &(&1 == 0)); percent = (relevant - missed) * 100.0 / relevant; rounded = Float.round(percent, 2); IO.puts("Coverage gate: #{rounded}%"); if percent < 90.0, do: System.halt(1)'
MIX_ENV=test mix run -e 'minimum = 73.0; data = Jason.decode!(File.read!("cover/excoveralls.json")); coverages = data["source_files"] |> Enum.flat_map(& &1["coverage"]); relevant = Enum.count(coverages, &(!is_nil(&1))); missed = Enum.count(coverages, &(&1 == 0)); percent = (relevant - missed) * 100.0 / relevant; rounded = Float.round(percent, 2); IO.puts("Coverage gate: #{rounded}% (minimum #{minimum}%)"); if percent < minimum, do: System.halt(1)'
39 changes: 20 additions & 19 deletions test/jido/shell/cancellation_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ defmodule Jido.Shell.CancellationTest do

alias Jido.Shell.ShellSession
alias Jido.Shell.ShellSessionServer
@event_timeout 1_000

setup do
workspace_id = "test_ws_#{System.unique_integer([:positive])}"
Expand All @@ -16,12 +17,12 @@ defmodule Jido.Shell.CancellationTest do
test "cancels running command", %{session_id: session_id} do
{:ok, :accepted} = ShellSessionServer.run_command(session_id, "sleep 10")

assert_receive {:jido_shell_session, _, {:command_started, "sleep 10"}}
assert_receive {:jido_shell_session, _, {:output, "Sleeping for 10 seconds...\n"}}
assert_receive {:jido_shell_session, _, {:command_started, "sleep 10"}}, @event_timeout
assert_receive {:jido_shell_session, _, {:output, "Sleeping for 10 seconds...\n"}}, @event_timeout

{:ok, :cancelled} = ShellSessionServer.cancel(session_id)

assert_receive {:jido_shell_session, _, :command_cancelled}
assert_receive {:jido_shell_session, _, :command_cancelled}, @event_timeout

{:ok, state} = ShellSessionServer.get_state(session_id)
refute state.current_command
Expand All @@ -36,41 +37,41 @@ defmodule Jido.Shell.CancellationTest do

test "allows new command after cancellation", %{session_id: session_id} do
{:ok, :accepted} = ShellSessionServer.run_command(session_id, "sleep 10")
assert_receive {:jido_shell_session, _, {:command_started, _}}
assert_receive {:jido_shell_session, _, {:command_started, _}}, @event_timeout

{:ok, :cancelled} = ShellSessionServer.cancel(session_id)
assert_receive {:jido_shell_session, _, :command_cancelled}
assert_receive {:jido_shell_session, _, :command_cancelled}, @event_timeout

wait_until_idle(session_id)

{:ok, :accepted} = ShellSessionServer.run_command(session_id, "echo done")
assert_receive {:jido_shell_session, _, {:command_started, "echo done"}}
assert_receive {:jido_shell_session, _, {:output, "done\n"}}, 1_000
assert_receive {:jido_shell_session, _, :command_done}, 1_000
assert_receive {:jido_shell_session, _, {:command_started, "echo done"}}, @event_timeout
assert_receive {:jido_shell_session, _, {:output, "done\n"}}, @event_timeout
assert_receive {:jido_shell_session, _, :command_done}, @event_timeout
end
end

describe "streaming" do
test "streams output chunks", %{session_id: session_id} do
{:ok, :accepted} = ShellSessionServer.run_command(session_id, "seq 3 10")

assert_receive {:jido_shell_session, _, {:command_started, _}}
assert_receive {:jido_shell_session, _, {:output, "1\n"}}
assert_receive {:jido_shell_session, _, {:output, "2\n"}}
assert_receive {:jido_shell_session, _, {:output, "3\n"}}
assert_receive {:jido_shell_session, _, :command_done}
assert_receive {:jido_shell_session, _, {:command_started, _}}, @event_timeout
assert_receive {:jido_shell_session, _, {:output, "1\n"}}, @event_timeout
assert_receive {:jido_shell_session, _, {:output, "2\n"}}, @event_timeout
assert_receive {:jido_shell_session, _, {:output, "3\n"}}, @event_timeout
assert_receive {:jido_shell_session, _, :command_done}, @event_timeout
end
end

describe "robustness" do
test "handles late messages from cancelled command", %{session_id: session_id} do
{:ok, :accepted} = ShellSessionServer.run_command(session_id, "seq 5 50")
assert_receive {:jido_shell_session, _, {:command_started, _}}
assert_receive {:jido_shell_session, _, {:command_started, _}}, @event_timeout

assert_receive {:jido_shell_session, _, {:output, "1\n"}}
assert_receive {:jido_shell_session, _, {:output, "1\n"}}, @event_timeout

{:ok, :cancelled} = ShellSessionServer.cancel(session_id)
assert_receive {:jido_shell_session, _, :command_cancelled}
assert_receive {:jido_shell_session, _, :command_cancelled}, @event_timeout

Process.sleep(100)

Expand All @@ -80,18 +81,18 @@ defmodule Jido.Shell.CancellationTest do

test "rejects command when busy", %{session_id: session_id} do
{:ok, :accepted} = ShellSessionServer.run_command(session_id, "sleep 5")
assert_receive {:jido_shell_session, _, {:command_started, _}}
assert_receive {:jido_shell_session, _, {:command_started, _}}, @event_timeout

assert {:error, %Jido.Shell.Error{code: {:shell, :busy}}} =
ShellSessionServer.run_command(session_id, "echo hello")

assert_receive {:jido_shell_session, _, {:error, %Jido.Shell.Error{code: {:shell, :busy}}}}
assert_receive {:jido_shell_session, _, {:error, %Jido.Shell.Error{code: {:shell, :busy}}}}, @event_timeout

{:ok, :cancelled} = ShellSessionServer.cancel(session_id)
end
end

defp wait_until_idle(session_id, attempts \\ 20)
defp wait_until_idle(session_id, attempts \\ 100)
defp wait_until_idle(_session_id, 0), do: :ok

defp wait_until_idle(session_id, attempts) do
Expand Down
38 changes: 21 additions & 17 deletions test/jido/shell/session_server_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ defmodule Jido.Shell.ShellSessionServerTest do

alias Jido.Shell.ShellSession
alias Jido.Shell.ShellSessionServer
@event_timeout 1_000

describe "start_link/1" do
test "starts a session server" do
Expand Down Expand Up @@ -124,9 +125,9 @@ defmodule Jido.Shell.ShellSessionServerTest do

{:ok, :accepted} = ShellSessionServer.run_command(session_id, "echo hello")

assert_receive {:jido_shell_session, ^session_id, {:command_started, "echo hello"}}
assert_receive {:jido_shell_session, ^session_id, {:output, "hello\n"}}
assert_receive {:jido_shell_session, ^session_id, :command_done}
assert_receive {:jido_shell_session, ^session_id, {:command_started, "echo hello"}}, @event_timeout
assert_receive {:jido_shell_session, ^session_id, {:output, "hello\n"}}, @event_timeout
assert_receive {:jido_shell_session, ^session_id, :command_done}, @event_timeout

{:ok, state} = ShellSessionServer.get_state(session_id)
assert "echo hello" in state.history
Expand All @@ -139,8 +140,10 @@ defmodule Jido.Shell.ShellSessionServerTest do

{:ok, :accepted} = ShellSessionServer.run_command(session_id, "unknown_cmd")

assert_receive {:jido_shell_session, ^session_id, {:command_started, "unknown_cmd"}}
assert_receive {:jido_shell_session, ^session_id, {:error, %Jido.Shell.Error{code: {:shell, :unknown_command}}}}
assert_receive {:jido_shell_session, ^session_id, {:command_started, "unknown_cmd"}}, @event_timeout

assert_receive {:jido_shell_session, ^session_id, {:error, %Jido.Shell.Error{code: {:shell, :unknown_command}}}},
@event_timeout
end

test "broadcasts busy error when command already running" do
Expand All @@ -149,12 +152,13 @@ defmodule Jido.Shell.ShellSessionServerTest do
{:ok, :subscribed} = ShellSessionServer.subscribe(session_id, self())

{:ok, :accepted} = ShellSessionServer.run_command(session_id, "sleep 5")
assert_receive {:jido_shell_session, ^session_id, {:command_started, "sleep 5"}}
assert_receive {:jido_shell_session, ^session_id, {:command_started, "sleep 5"}}, @event_timeout

assert {:error, %Jido.Shell.Error{code: {:shell, :busy}}} =
ShellSessionServer.run_command(session_id, "echo second")

assert_receive {:jido_shell_session, ^session_id, {:error, %Jido.Shell.Error{code: {:shell, :busy}}}}
assert_receive {:jido_shell_session, ^session_id, {:error, %Jido.Shell.Error{code: {:shell, :busy}}}},
@event_timeout

{:ok, :cancelled} = ShellSessionServer.cancel(session_id)
end
Expand All @@ -166,9 +170,9 @@ defmodule Jido.Shell.ShellSessionServerTest do

{:ok, :accepted} = ShellSessionServer.run_command(session_id, "pwd")

assert_receive {:jido_shell_session, ^session_id, {:command_started, "pwd"}}
assert_receive {:jido_shell_session, ^session_id, {:output, "/home/user\n"}}
assert_receive {:jido_shell_session, ^session_id, :command_done}
assert_receive {:jido_shell_session, ^session_id, {:command_started, "pwd"}}, @event_timeout
assert_receive {:jido_shell_session, ^session_id, {:output, "/home/user\n"}}, @event_timeout
assert_receive {:jido_shell_session, ^session_id, :command_done}, @event_timeout
end

test "clears current_command after completion" do
Expand All @@ -178,7 +182,7 @@ defmodule Jido.Shell.ShellSessionServerTest do

{:ok, :accepted} = ShellSessionServer.run_command(session_id, "echo test")

assert_receive {:jido_shell_session, ^session_id, :command_done}
assert_receive {:jido_shell_session, ^session_id, :command_done}, @event_timeout

{:ok, state} = ShellSessionServer.get_state(session_id)
assert state.current_command == nil
Expand All @@ -191,9 +195,9 @@ defmodule Jido.Shell.ShellSessionServerTest do
{:ok, server_pid} = ShellSession.lookup(session_id)

GenServer.cast(server_pid, {:run_command, "echo cast", []})
assert_receive {:jido_shell_session, ^session_id, {:command_started, "echo cast"}}
assert_receive {:jido_shell_session, ^session_id, {:output, "cast\n"}}
assert_receive {:jido_shell_session, ^session_id, :command_done}
assert_receive {:jido_shell_session, ^session_id, {:command_started, "echo cast"}}, @event_timeout
assert_receive {:jido_shell_session, ^session_id, {:output, "cast\n"}}, @event_timeout
assert_receive {:jido_shell_session, ^session_id, :command_done}, @event_timeout

# Idle cancel cast should be a no-op with explicit invalid transition handling internally.
GenServer.cast(server_pid, :cancel)
Expand All @@ -208,12 +212,12 @@ defmodule Jido.Shell.ShellSessionServerTest do
{:ok, server_pid} = ShellSession.lookup(session_id)

{:ok, :accepted} = ShellSessionServer.run_command(session_id, "sleep 1")
assert_receive {:jido_shell_session, ^session_id, {:command_started, "sleep 1"}}
assert_receive {:jido_shell_session, ^session_id, {:command_started, "sleep 1"}}, @event_timeout
{:ok, state} = ShellSessionServer.get_state(session_id)
assert %{ref: ref, task: task_pid} = state.current_command

send(server_pid, {:DOWN, ref, :process, task_pid, :boom})
assert_receive {:jido_shell_session, ^session_id, {:command_crashed, :boom}}
assert_receive {:jido_shell_session, ^session_id, {:command_crashed, :boom}}, @event_timeout
end

test "ignores late command events and late finished messages after cancellation" do
Expand Down Expand Up @@ -268,7 +272,7 @@ defmodule Jido.Shell.ShellSessionServerTest do
end
end)

assert_receive :registered
assert_receive :registered, @event_timeout
assert Process.alive?(pid)

assert {:error, %Jido.Shell.Error{code: {:session, :not_found}}} =
Expand Down