defmodule OpenAgents.Forge.AssignmentTest do
use OpenAgents.DataCase, async: true
import Ecto.Query
import OpenAgents.AccountsFixtures
alias OpenAgents.Forge.{Assignment, AssignmentCredential}
alias OpenAgents.Accounts
alias OpenAgents.Agents
alias OpenAgents.Box.ConversationBox
alias OpenAgents.Conversations
alias OpenAgents.Forge.GitReceivePack
alias OpenAgents.Issues
alias OpenAgents.Issues.Comment
alias OpenAgents.Repo
alias OpenAgents.Repositories
test "assignment changesets require a branch and lifecycle timestamps" do
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
changeset =
Assignment.changeset(%Assignment{}, %{
conversation_box_id: Ecto.UUID.generate(),
repository_id: Ecto.UUID.generate(),
issue_id: 1,
requesting_principal: %{"type" => "agent", "id" => Ecto.UUID.generate()},
branch: "agent/issue-1",
deadline_at: now,
admitted_at: now
})
assert changeset.valid?
assignment = Ecto.Changeset.apply_changes(changeset)
assert assignment.state == "admitted"
refute Assignment.terminal?(assignment)
end
test "assignment changesets support a Computer target without changing Box defaults" do
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
changeset =
Assignment.changeset(%Assignment{}, %{
target_kind: "computer",
machine_id: Ecto.UUID.generate(),
conversation_id: Ecto.UUID.generate(),
repository_id: Ecto.UUID.generate(),
issue_id: 1,
requesting_principal: %{"type" => "agent", "id" => Ecto.UUID.generate()},
branch: "agent/computer-issue-1",
deadline_at: DateTime.add(now, 60, :second),
admitted_at: now
})
assert changeset.valid?
assignment = Ecto.Changeset.apply_changes(changeset)
assert assignment.target_kind == "computer"
assert assignment.machine_id
assert is_nil(assignment.conversation_box_id)
end
test "Computer assignments require a machine target" do
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
changeset =
Assignment.changeset(%Assignment{}, %{
target_kind: "computer",
conversation_id: Ecto.UUID.generate(),
repository_id: Ecto.UUID.generate(),
issue_id: 1,
requesting_principal: %{"type" => "agent", "id" => Ecto.UUID.generate()},
branch: "agent/computer-issue-1",
deadline_at: DateTime.add(now, 60, :second),
admitted_at: now
})
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).machine_id
end
test "assignment credentials keep only a digest and metadata" do
digest = :crypto.hash(:sha256, "oa_assignment_secret")
changeset =
AssignmentCredential.changeset(%AssignmentCredential{}, %{
assignment_id: Ecto.UUID.generate(),
token_digest: digest,
last_four: "cret",
repository_id: Ecto.UUID.generate(),
branch: "agent/issue-1",
expires_at: DateTime.add(DateTime.utc_now(), 60, :second)
})
assert changeset.valid?
credential = Ecto.Changeset.apply_changes(changeset)
assert credential.token_digest == digest
refute Map.has_key?(Map.from_struct(credential), :token)
refute Map.has_key?(Map.from_struct(credential), :plaintext)
end
test "invalid assignment credentials are refused" do
assert {:error, :invalid_assignment_credential} =
OpenAgents.Forge.Assignments.authenticate("oa_assignment_not-a-credential")
end
test "Box control grants require a linked human and are revocable" do
{:ok, agent, _token} =
Agents.register(%{
handle: "assignment-grant-agent",
display_name: "Assignment grant agent",
registration_ip: "192.0.2.55"
})
{:ok, user} =
Accounts.upsert_github_user(%{
github_id: 9_991_055,
github_login: "assignment-grant-user",
github_avatar_url: "https://avatars.githubusercontent.com/u/9991055?v=4"
})
{:ok, link} = Agents.request_link(agent, user)
{:ok, _link} = Agents.accept_link(user, link.id)
refute Agents.box_control_granted?(agent)
assert {:ok, grant} = Agents.grant_box_control(user, agent)
assert grant.granted_by_id == user.id
assert Agents.box_control_granted?(agent)
assert {:ok, revoked} = Agents.revoke_box_control(user, agent)
assert revoked.revoked_at
refute Agents.box_control_granted?(agent)
end
test "receive-pack parsing accepts shallow lines and trailing newlines" do
shallow = pkt_line("shallow " <> String.duplicate("a", 40) <> "\n")
update =
pkt_line(
String.duplicate("0", 40) <>
" " <> String.duplicate("b", 40) <> " refs/heads/agent/issue-1\n"
)
assert {:ok, ["refs/heads/agent/issue-1"]} =
GitReceivePack.refs(shallow <> update <> "0000")
assert {:error, :invalid_receive_pack} =
GitReceivePack.refs(pkt_line("malformed\n") <> "0000")
end
test "an attempt narrates nothing, and its terminal record carries what the prose did" do
user = repository_user_fixture("assignment-timeline")
{:ok, repository} =
Repositories.create_repository(%{
owner: "AssignmentTimeline",
name: "assignment-timeline-#{System.unique_integer([:positive])}",
visibility: "private"
})
{:ok, issue} = Issues.create_issue(repository, %{title: "Timeline assignment"})
{:ok, conversation} = Conversations.ensure_conversation("assignment-timeline")
{:ok, box} =
%ConversationBox{}
|> ConversationBox.changeset(%{
conversation_id: conversation.id,
box_id: "bx_assignment_timeline",
state: "ready",
setup_status: "done"
})
|> Repo.insert()
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
assignment =
%Assignment{}
|> Assignment.changeset(%{
conversation_box_id: box.id,
repository_id: repository.id,
issue_id: issue.id,
requesting_principal: %{
"type" => "user",
"id" => user.id,
"actor_type" => "user",
"actor_id" => user.id
},
branch: "agent/timeline",
deadline_at: DateTime.add(now, 60, :second),
admitted_at: now
})
|> Repo.insert!()
%AssignmentCredential{}
|> AssignmentCredential.changeset(%{
assignment_id: assignment.id,
token_digest: :crypto.hash(:sha256, "oa_assignment_timeline"),
last_four: "abcd",
repository_id: repository.id,
branch: "agent/timeline",
expires_at: DateTime.add(now, 60, :second)
})
|> Repo.insert!()
assert {:ok, finished} =
OpenAgents.Forge.Assignments.finish(assignment, "failed", nil, "test")
# The claim, the result, and the release were three Markdown comments the
# attempt wrote about itself. Every fact in them is in this row, so the
# timeline derives them and the write path writes nothing.
assert Repo.all(from comment in Comment, where: comment.issue_id == ^issue.id) == []
assert finished.state == "failed"
assert finished.failure_reason == "test"
assert finished.finished_at
assert finished.terminal_branch == "agent/timeline"
# "Claim released" described the credential revocation, and this is the
# record it described. `finish/1` revokes in the same transaction that
# makes the attempt terminal, for every terminal state — the comment fired
# for only two of the three, and nothing compared the two.
assert [%{revoked_at: revoked_at}] =
Repo.all(
from credential in AssignmentCredential,
where: credential.assignment_id == ^assignment.id
)
assert revoked_at
assert Repo.get!(OpenAgents.Issues.Issue, issue.id).state == "open"
end
defp pkt_line(line) do
line
|> byte_size()
|> Kernel.+(4)
|> Integer.to_string(16)
|> String.pad_leading(4, "0")
|> Kernel.<>(line)
end
end