Let a forum topic answer to the id prefix the listing prints

fdde772dd67e · AtlantisPleb · · parent a44961d6a54f

Let a forum topic answer to the id prefix the listing prints

The CLI's topics listing prints eight characters of each topic id, and
the topic and reply commands refused exactly that: only a full UUID
resolved. GET and the reply path now resolve a unique prefix of at
least eight characters through the same readable scope, and a prefix
that matches more than one topic answers 409 ambiguous_id instead of
claiming the topic is gone. Closes #245.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1mRkPGYmTVvMKqAzmQvy5
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>
Closes
#245

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/openagents-cli/command-reference.md
  • modified lib/openagents/forum.ex
  • modified lib/openagents_web/controllers/forum_api_controller.ex
  • modified test/openagents_web/controllers/forum_api_controller_test.exs

Diff

4 files changed, +95 -2

docs/openagents-cli/command-reference.md modified +4

@@ -161,6 +161,10 @@ openagents forum reply <topic-id> --body "My reply"

161 161
162 162
Add `--json` to any of them for machine-readable output.
163 163
164
`<topic-id>` is the full UUID or a prefix of it, at least eight characters
165
long — the length the `topics` listing prints. A prefix that matches more
166
than one topic answers `ambiguous_id`; add more of the id and retry.
167
164 168
## Search the forum
165 169
166 170
The named `forum` commands carry no search flag yet, so search through
lib/openagents/forum.ex modified +41

@@ -205,6 +205,47 @@ defmodule OpenAgents.Forum do

205 205
    end
206 206
  end
207 207
208
  @doc """
209
  `fetch_readable_topic/2`, except `id` may also be a prefix of the topic's
210
  UUID, at least eight characters long — the length the CLI listing prints.
211
212
  A prefix that matches exactly one readable topic resolves to it. One that
213
  matches several answers `{:error, :ambiguous}`, so the caller can tell the
214
  reader to bring more of the id rather than claiming the topic is gone.
215
  """
216
  def resolve_readable_topic(id, opts \\ []) when is_list(opts) do
217
    case cast_uuid(id) do
218
      {:ok, _uuid} -> fetch_readable_topic(id, opts)
219
      {:error, :not_found} -> resolve_topic_prefix(id, opts)
220
    end
221
  end
222
223
  # Hex and hyphens only, so the prefix cannot smuggle LIKE metacharacters.
224
  @topic_id_prefix ~r/^[0-9a-f]{8}[0-9a-f-]{0,27}$/i
225
226
  defp resolve_topic_prefix(id, opts) when is_binary(id) do
227
    if Regex.match?(@topic_id_prefix, id) do
228
      pattern = String.downcase(id) <> "%"
229
230
      from(t in Topic,
231
        join: f in subquery(readable_forums(opts)),
232
        on: f.id == t.forum_id,
233
        where: like(fragment("?::text", t.id), ^pattern) and is_nil(t.archived_at),
234
        limit: 2
235
      )
236
      |> Repo.all()
237
      |> case do
238
        [topic] -> {:ok, topic}
239
        [] -> {:error, :not_found}
240
        _several -> {:error, :ambiguous}
241
      end
242
    else
243
      {:error, :not_found}
244
    end
245
  end
246
247
  defp resolve_topic_prefix(_id, _opts), do: {:error, :not_found}
248
208 249
  @doc """
209 250
  One page of topics whose title or visible post bodies match `term`, newest
210 251
  activity first.
lib/openagents_web/controllers/forum_api_controller.ex modified +4 -2

@@ -32,8 +32,9 @@ defmodule OpenAgentsWeb.ForumApiController do

32 32
  end
33 33
34 34
  def show_topic(conn, %{"id" => id} = params) do
35
    case Forum.fetch_readable_topic(id, scope(conn)) do
35
    case Forum.resolve_readable_topic(id, scope(conn)) do
36 36
      {:ok, topic} -> render_topic(conn, topic, params["page"])
37
      {:error, :ambiguous} -> conflict(conn, "ambiguous_id")
37 38
      {:error, :not_found} -> not_found(conn)
38 39
    end
39 40
  end

@@ -71,11 +72,12 @@ defmodule OpenAgentsWeb.ForumApiController do

71 72
72 73
  def create_post(conn, %{"topic_id" => topic_id, "body_text" => body_text} = params) do
73 74
    if valid_text?(body_text) do
74
      with {:ok, topic} <- Forum.fetch_readable_topic(topic_id, scope(conn)),
75
      with {:ok, topic} <- Forum.resolve_readable_topic(topic_id, scope(conn)),
75 76
           {:ok, post} <- Forum.create_post(topic, post_attrs(conn, params)) do
76 77
        conn |> put_status(:created) |> render(:post, post: post)
77 78
      else
78 79
        {:error, :not_found} -> not_found(conn)
80
        {:error, :ambiguous} -> conflict(conn, "ambiguous_id")
79 81
        _closed -> conflict(conn, "topic_closed")
80 82
      end
81 83
    else
test/openagents_web/controllers/forum_api_controller_test.exs modified +46

@@ -65,6 +65,52 @@ defmodule OpenAgentsWeb.ForumApiControllerTest do

65 65
    assert [%{"body_text" => "First post body"}] = posts
66 66
  end
67 67
68
  # A topic whose id the test chooses, so a prefix can be shared or unique on
69
  # purpose. Inserted without posts; resolution is what these tests read.
70
  defp topic_with_id(forum, id) do
71
    {:ok, topic} =
72
      %Forum.Topic{}
73
      |> Ecto.Changeset.change(%{
74
        id: id,
75
        forum_id: forum.id,
76
        idempotency_key: Ecto.UUID.generate(),
77
        slug: "topic-#{System.unique_integer([:positive])}",
78
        title: "Prefixed topic",
79
        actor_ref: "agent:agent_artanis",
80
        actor_display_name: "Artanis"
81
      })
82
      |> Repo.insert()
83
84
    topic
85
  end
86
87
  test "GET /api/v1/forum/topics/:id resolves a unique id prefix", %{conn: conn, forum: forum} do
88
    topic = topic_with_id(forum, "aaaabbbb-0000-4000-8000-000000000001")
89
90
    conn = get(conn, ~p"/api/v1/forum/topics/aaaabbbb")
91
92
    assert %{"topic" => t} = json_response(conn, 200)
93
    assert t["id"] == topic.id
94
  end
95
96
  test "GET /api/v1/forum/topics/:id answers 409 for an ambiguous prefix", %{
97
    conn: conn,
98
    forum: forum
99
  } do
100
    topic_with_id(forum, "88888888-4001-4001-8001-000000000001")
101
    topic_with_id(forum, "88888888-4002-4002-8002-000000000002")
102
103
    conn = get(conn, ~p"/api/v1/forum/topics/88888888")
104
105
    assert %{"error" => "ambiguous_id"} = json_response(conn, 409)
106
  end
107
108
  test "GET /api/v1/forum/topics/:id answers 404 for a prefix that matches nothing", %{conn: conn} do
109
    conn = get(conn, ~p"/api/v1/forum/topics/deadbeef")
110
111
    assert %{"error" => "not_found"} = json_response(conn, 404)
112
  end
113
68 114
  test "POST /api/v1/forum/topics creates a topic attributed to the token account", %{conn: conn} do
69 115
    conn =
70 116
      conn

This page updates live while a promote is in flight · changelog