Say what a repository is doing and where it came from

df44ff812e96 · AtlantisPleb · · parent d76965ffe33f

Say what a repository is doing and where it came from

The repository index listed a name, a visibility and a lifecycle word. It
did not say when a repository was last touched, that an imported one is a
one-time copy of a GitHub repository that never resynchronizes, or what
provisioning was actually doing while it ran.

Each row now carries a coarse updated time, the GitHub source when there is
one, and a stage read from the two durable receipts: the provisioning outbox
row and the import row. The provisioner and the importer announce every
transition on the repository's own topic once the owning transaction commits,
and the index subscribes only to repositories that can still move, so a page
of ready repositories opens no topics and a settled one drops its own. The
message carries an id, so DATA-001 holds and every subscriber re-reads
through its own visibility predicate.

The repository page states the same provenance in full: source, accepted
snapshot, import state, completion time and bounded failure code, with the
statement that OpenAgents copied the snapshot once and owns it now. It also
follows its own provisioning to ready instead of asking for a refresh.

The index gains a collapsed panel of the real CLI commands and the clone URL
shape, and the application sidebar finally has a row that reaches it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149rBWy7br1Z7bbz9NrQhEr
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

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 lib/openagents/repositories.ex
  • modified lib/openagents/repositories/importer.ex
  • modified lib/openagents/repositories/provisioner.ex
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/live/code_repo_live.ex
  • modified lib/openagents_web/live/repository_index_live.ex
  • modified test/openagents/repositories/provisioner_test.exs
  • modified test/openagents_web/live/code_live_test.exs
  • modified test/openagents_web/live/repository_live_test.exs

Diff

9 files changed, +738 -39

lib/openagents/repositories.ex modified +84 -1

@@ -23,6 +23,12 @@ defmodule OpenAgents.Repositories do

23 23
  @writable_roles ~w(owner maintainer contributor)
24 24
  @repository_namespace_limit 100
25 25
26
  # The two durable receipts that say where a repository is in provisioning:
27
  # the outbox row is the work, the import row is the GitHub snapshot. Both are
28
  # `has_one`, so they are preloaded together wherever a surface renders
29
  # progress or provenance.
30
  @provisioning_assocs [:repository_import, :provisioning_outbox]
31
26 32
  def initial_path, do: {@initial_owner, @initial_name}
27 33
28 34
  def initial_repository! do

@@ -279,10 +285,87 @@ defmodule OpenAgents.Repositories do

279 285
      |> apply_namespace_filter(namespace_key)
280 286
      |> apply_repository_cursor(after_cursor)
281 287
282
    rows = Repo.all(from row in query, limit: ^(per_page + 1))
288
    rows =
289
      from(row in query, limit: ^(per_page + 1))
290
      |> Repo.all()
291
      |> Repo.preload(@provisioning_assocs)
292
283 293
    {Enum.take(rows, per_page), length(rows) > per_page}
284 294
  end
285 295
296
  @doc """
297
  One repository the user may see, by id, with its provisioning receipts.
298
299
  The list page's per-row counterpart: a surface that has already rendered a
300
  row and then hears the repository changed reloads exactly that row rather
301
  than the whole page. Returns `nil` rather than raising, because a repository
302
  can stop being visible between the broadcast and the read.
303
  """
304
  def get_visible_repository(id, user)
305
306
  def get_visible_repository(id, nil) when is_binary(id) do
307
    visible_repository(
308
      from repository in Repository,
309
        join: namespace in assoc(repository, :namespace),
310
        where: repository.id == ^id,
311
        where: repository.visibility == "public" and repository.lifecycle_state == "ready",
312
        preload: [namespace: namespace]
313
    )
314
  end
315
316
  def get_visible_repository(id, %User{id: user_id}) when is_binary(id) do
317
    visible_repository(
318
      from repository in Repository,
319
        join: namespace in assoc(repository, :namespace),
320
        left_join: membership in Membership,
321
        on: membership.repository_id == repository.id and membership.user_id == ^user_id,
322
        where: repository.id == ^id,
323
        where:
324
          (repository.visibility == "public" and repository.lifecycle_state == "ready") or
325
            (not is_nil(membership.user_id) and
326
               membership.role in ^~w(owner maintainer contributor viewer)),
327
        preload: [namespace: namespace]
328
    )
329
  end
330
331
  defp visible_repository(query) do
332
    case Repo.one(query) do
333
      nil -> nil
334
      %Repository{} = repository -> Repo.preload(repository, @provisioning_assocs)
335
    end
336
  end
337
338
  @doc """
339
  Subscribes the caller to one repository's provisioning transitions.
340
341
  DATA-001: PostgreSQL stays authoritative. The message carries the repository
342
  id and nothing else, so a subscriber re-reads through its own visibility
343
  predicate and can never be handed a row the database would not have given it.
344
  """
345
  def subscribe_provisioning(repository_id) when is_binary(repository_id),
346
    do: Phoenix.PubSub.subscribe(OpenAgents.PubSub, provisioning_topic(repository_id))
347
348
  @doc "Stops the caller hearing about one repository, once it has settled."
349
  def unsubscribe_provisioning(repository_id) when is_binary(repository_id),
350
    do: Phoenix.PubSub.unsubscribe(OpenAgents.PubSub, provisioning_topic(repository_id))
351
352
  @doc """
353
  Announces that one repository's provisioning or import state moved.
354
355
  Called after the owning transaction commits, never inside it: a subscriber
356
  re-reads immediately, and a message sent from inside the transaction races
357
  the commit and hands it the old row.
358
  """
359
  def broadcast_provisioning(repository_id) when is_binary(repository_id) do
360
    Phoenix.PubSub.broadcast(
361
      OpenAgents.PubSub,
362
      provisioning_topic(repository_id),
363
      {:repository_provisioning, repository_id}
364
    )
365
  end
366
367
  defp provisioning_topic(repository_id), do: "repository:" <> repository_id
368
286 369
  defp apply_namespace_filter(query, nil), do: query
287 370
288 371
  defp apply_namespace_filter(query, namespace_key) when is_binary(namespace_key) do
lib/openagents/repositories/importer.ex modified +12

@@ -399,6 +399,7 @@ defmodule OpenAgents.Repositories.Importer do

399 399
      running
400 400
    end)
401 401
    |> elem(1)
402
    |> announce()
402 403
  end
403 404
404 405
  defp mark_completed!(repository_import) do

@@ -418,6 +419,7 @@ defmodule OpenAgents.Repositories.Importer do

418 419
      completed
419 420
    end)
420 421
    |> elem(1)
422
    |> announce()
421 423
  end
422 424
423 425
  defp mark_failed!(repository_import, error_code) do

@@ -437,6 +439,16 @@ defmodule OpenAgents.Repositories.Importer do

437 439
      failed
438 440
    end)
439 441
    |> elem(1)
442
    |> announce()
443
  end
444
445
  # Every import transition is announced on the repository's own topic once the
446
  # transaction holding it has committed. A copy from GitHub is the longest
447
  # thing a repository does before it is usable, and the browser has no other
448
  # way to learn that it moved from queued to copying.
449
  defp announce(%RepositoryImport{} = repository_import) do
450
    OpenAgents.Repositories.broadcast_provisioning(repository_import.repository_id)
451
    repository_import
440 452
  end
441 453
442 454
  defp audit_import_transition!(repository_import) do
lib/openagents/repositories/provisioner.ex modified +5

@@ -117,6 +117,9 @@ defmodule OpenAgents.Repositories.Provisioner do

117 117
      end)
118 118
119 119
    if work do
120
      # After the claim commits: a browser watching this repository moves from
121
      # "queued" to "running" the moment the row does.
122
      OpenAgents.Repositories.broadcast_provisioning(work.repository_id)
120 123
      Repo.preload(work, repository: [:created_by_user, :repository_import])
121 124
    end
122 125
  end

@@ -203,6 +206,7 @@ defmodule OpenAgents.Repositories.Provisioner do

203 206
      )
204 207
    end)
205 208
209
    OpenAgents.Repositories.broadcast_provisioning(work.repository_id)
206 210
    :ok
207 211
  end
208 212

@@ -243,6 +247,7 @@ defmodule OpenAgents.Repositories.Provisioner do

243 247
      )
244 248
    end)
245 249
250
    OpenAgents.Repositories.broadcast_provisioning(work.repository_id)
246 251
    Logger.warning("repository_provisioning_failed code=provisioning_failed")
247 252
    :ok
248 253
  end
lib/openagents_web/components/layouts.ex modified +6

@@ -601,6 +601,12 @@ defmodule OpenAgentsWeb.Layouts do

601 601
602 602
      <nav class="sidebar-nav" aria-label="OpenAgents surfaces">
603 603
        <Layouts.sidebar_link path={~p"/"} label="Home" icon="home" patchable={false} />
604
        <Layouts.sidebar_link
605
          path={~p"/repositories"}
606
          label="Repositories"
607
          icon="branch"
608
          patchable={false}
609
        />
604 610
        <Layouts.sidebar_link
605 611
          path={~p"/OpenAgentsInc/openagents.com/issues"}
606 612
          label="Issues"
lib/openagents_web/live/code_repo_live.ex modified +68 -1

@@ -11,6 +11,7 @@ defmodule OpenAgentsWeb.CodeRepoLive do

11 11
12 12
  alias OpenAgents.Forge
13 13
  alias OpenAgents.Forge.Browse
14
  alias OpenAgents.Repositories
14 15
  alias OpenAgentsWeb.RepositoryAccess
15 16
16 17
  @impl true

@@ -41,10 +42,19 @@ defmodule OpenAgentsWeb.CodeRepoLive do

41 42
        _ -> []
42 43
      end
43 44
45
    # A repository that is still provisioning is the one state this page cannot
46
    # render usefully, and it is also the one state that ends on its own. The
47
    # provisioner and the importer announce each transition, so the page hears
48
    # them instead of telling the reader to keep pressing refresh.
49
    if connected?(socket) and repository.lifecycle_state != "ready" do
50
      :ok = Repositories.subscribe_provisioning(repository.id)
51
    end
52
44 53
    {:ok,
45 54
     socket
46 55
     |> assign(:page_title, "#{repository.name} · code")
47 56
     |> assign(:repository, repository)
57
     |> assign(:repository_import, repository.repository_import)
48 58
     |> assign(:repo, repository.name)
49 59
     |> assign(:owner, repository.namespace.slug)
50 60
     |> assign(:base, base)

@@ -57,6 +67,25 @@ defmodule OpenAgentsWeb.CodeRepoLive do

57 67
    Ecto.NoResultsError -> raise OpenAgentsWeb.PublicNotFoundError
58 68
  end
59 69
70
  @impl true
71
  def handle_info({:repository_provisioning, repository_id}, socket) do
72
    case Repositories.get_visible_repository(repository_id, socket.assigns.current_user) do
73
      nil ->
74
        {:noreply, socket}
75
76
      %{lifecycle_state: "ready"} ->
77
        # Ready means there is now a head, a README, commits, and refs to read.
78
        # Remounting the route loads them the one way this page knows how.
79
        {:noreply, push_navigate(socket, to: socket.assigns.base)}
80
81
      repository ->
82
        {:noreply,
83
         socket
84
         |> assign(:repository, repository)
85
         |> assign(:repository_import, repository.repository_import)}
86
    end
87
  end
88
60 89
  defp short(sha), do: String.slice(sha, 0, 12)
61 90
62 91
  @impl true

@@ -95,7 +124,7 @@ defmodule OpenAgentsWeb.CodeRepoLive do

95 124
            variant={:info}
96 125
            title="Repository provisioning is in progress"
97 126
          >
98
            Refresh this page after the repository becomes ready for Git operations.
127
            This page updates itself when the repository becomes ready for Git operations.
99 128
          </.alert>
100 129
101 130
          <.alert

@@ -107,6 +136,44 @@ defmodule OpenAgentsWeb.CodeRepoLive do

107 136
            Error code: <code>{@repository.provision_error_code || "provisioning_failed"}</code>
108 137
          </.alert>
109 138
139
          <%!-- REPOSITORY-001: an import freezes one authorized ref map and
140
          schedules no later synchronization, so this states the source, what
141
          was accepted, and that nothing keeps the two in step. Never labelled
142
          synced or mirrored. --%>
143
          <.card :if={@repository_import} id="repo-import-provenance">
144
            <h2>Imported once from GitHub</h2>
145
            <dl class="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-[auto_1fr]">
146
              <dt class="text-muted-foreground">Source</dt>
147
              <dd><code>{@repository_import.source_full_name}</code></dd>
148
149
              <dt :if={@repository_import.source_head_sha} class="text-muted-foreground">
150
                Accepted snapshot
151
              </dt>
152
              <dd :if={@repository_import.source_head_sha}>
153
                <code>{short(@repository_import.source_head_sha)}</code>
154
              </dd>
155
156
              <dt class="text-muted-foreground">State</dt>
157
              <dd>{@repository_import.state}</dd>
158
159
              <dt :if={@repository_import.completed_at} class="text-muted-foreground">Completed</dt>
160
              <dd :if={@repository_import.completed_at}>
161
                <time datetime={DateTime.to_iso8601(@repository_import.completed_at)}>
162
                  {Calendar.strftime(@repository_import.completed_at, "%Y-%m-%d %H:%M UTC")}
163
                </time>
164
              </dd>
165
166
              <dt :if={@repository_import.error_code} class="text-muted-foreground">Error code</dt>
167
              <dd :if={@repository_import.error_code}>
168
                <code>{@repository_import.error_code}</code>
169
              </dd>
170
            </dl>
171
            <p class="mt-3 text-sm text-muted-foreground">
172
              OpenAgents copied this snapshot once and is now the source of truth for it.
173
              Later commits on GitHub do not appear here.
174
            </p>
175
          </.card>
176
110 177
          <.card :if={@repository.lifecycle_state == "ready"} id="repo-clone">
111 178
            <h2>Clone</h2>
112 179
            <code class="block break-all">git clone {@clone_url}</code>
lib/openagents_web/live/repository_index_live.ex modified +308 -36

@@ -1,5 +1,23 @@

1 1
defmodule OpenAgentsWeb.RepositoryIndexLive do
2
  @moduledoc "Lists repositories visible to the signed-in user."
2
  @moduledoc """
3
  Every repository the signed-in account can reach, and what each one is doing.
4
5
  Three things beyond the name. **Updated time**, because a list of thirty
6
  repositories sorted by name gives no sense of which ones are alive.
7
  **Provenance**, because an imported repository is a one-time copy of a GitHub
8
  repository (REPOSITORY-001) and a reader who does not know that will expect
9
  it to keep up with the source. **Progress**, because provisioning is the one
10
  moment a repository exists but cannot be cloned, and a badge reading
11
  `provisioning` says only that, not whether the copy has started, how many
12
  attempts it has taken, or why it stopped.
13
14
  Progress is read from the two durable receipts — the provisioning outbox row
15
  and the import row — and refreshed over PubSub rather than by polling: the
16
  provisioner and the importer already commit those transitions, so announcing
17
  them costs one message per transition instead of one query per second per
18
  open browser. DATA-001 holds; the message carries an id, and this view
19
  re-reads through its own visibility predicate.
20
  """
3 21
4 22
  use OpenAgentsWeb, :live_view
5 23

@@ -7,6 +25,18 @@ defmodule OpenAgentsWeb.RepositoryIndexLive do

7 25
8 26
  @per_page 20
9 27
28
  # `<name>` and `<owner>/<repo>` are placeholders the reader substitutes, so
29
  # they are interpolated rather than written into the template, where the HEEx
30
  # parser would read them as tags.
31
  @cli_steps [
32
    %{command: "npm i -g @openagentsinc/cli", note: "install"},
33
    %{command: "openagents auth login", note: "sign in"},
34
    %{command: "openagents repo create <name>", note: "create"},
35
    %{command: "openagents repo clone <owner>/<repo>", note: "clone"},
36
    %{command: "openagents repo import <owner>/<repo>", note: "import once"},
37
    %{command: "openagents auth setup-git --local", note: "authenticate git"}
38
  ]
39
10 40
  @impl true
11 41
  def mount(_params, _session, socket) do
12 42
    {repositories, more?} =

@@ -21,7 +51,11 @@ defmodule OpenAgentsWeb.RepositoryIndexLive do

21 51
     |> assign(:page_title, "Repositories")
22 52
     |> assign(:repository_cursor, cursor(List.last(repositories)))
23 53
     |> assign(:repositories_more?, more?)
24
     |> stream(:repositories, repositories)}
54
     |> assign(:watching, MapSet.new())
55
     |> assign(:cli_steps, @cli_steps)
56
     |> assign(:clone_url_shape, OpenAgentsWeb.Endpoint.url() <> "/git/<owner>/<name>.git")
57
     |> stream(:repositories, repositories)
58
     |> watch(repositories)}
25 59
  end
26 60
27 61
  @impl true

@@ -40,7 +74,56 @@ defmodule OpenAgentsWeb.RepositoryIndexLive do

40 74
       cursor(List.last(repositories)) || socket.assigns.repository_cursor
41 75
     )
42 76
     |> assign(:repositories_more?, more?)
43
     |> stream(:repositories, repositories)}
77
     |> stream(:repositories, repositories)
78
     |> watch(repositories)}
79
  end
80
81
  @impl true
82
  def handle_info({:repository_provisioning, repository_id}, socket) do
83
    case Repositories.get_visible_repository(repository_id, socket.assigns.current_user) do
84
      nil ->
85
        {:noreply, unwatch(socket, repository_id)}
86
87
      repository ->
88
        socket = stream_insert(socket, :repositories, repository)
89
90
        # Nothing else will ever move, so stop listening. This is what bounds
91
        # the subscription set: a page left open overnight holds topics only
92
        # for repositories still doing something.
93
        if repository.lifecycle_state == "provisioning" do
94
          {:noreply, socket}
95
        else
96
          {:noreply, unwatch(socket, repository_id)}
97
        end
98
    end
99
  end
100
101
  # Subscribing only to repositories that can still change. A ready repository
102
  # never transitions again, and a list of a hundred of them would otherwise
103
  # open a hundred topics that never carry a message.
104
  defp watch(socket, repositories) do
105
    if connected?(socket) do
106
      Enum.reduce(repositories, socket, fn repository, socket ->
107
        if repository.lifecycle_state == "provisioning" and
108
             repository.id not in socket.assigns.watching do
109
          :ok = Repositories.subscribe_provisioning(repository.id)
110
          assign(socket, :watching, MapSet.put(socket.assigns.watching, repository.id))
111
        else
112
          socket
113
        end
114
      end)
115
    else
116
      socket
117
    end
118
  end
119
120
  defp unwatch(socket, repository_id) do
121
    if repository_id in socket.assigns.watching do
122
      :ok = Repositories.unsubscribe_provisioning(repository_id)
123
      assign(socket, :watching, MapSet.delete(socket.assigns.watching, repository_id))
124
    else
125
      socket
126
    end
44 127
  end
45 128
46 129
  @impl true

@@ -53,7 +136,7 @@ defmodule OpenAgentsWeb.RepositoryIndexLive do

53 136
      sidebar_sections={assigns[:sidebar_sections]}
54 137
      wide
55 138
    >
56
      <main id="repository-index" class="mx-auto w-full max-w-6xl space-y-8 px-4 py-10">
139
      <main id="repository-index" class="mx-auto w-full max-w-6xl space-y-6 px-4 py-10">
57 140
        <.header>
58 141
          Repositories
59 142
          <:subtitle>Create an OpenAgents repository or copy one from GitHub once.</:subtitle>

@@ -69,38 +152,22 @@ defmodule OpenAgentsWeb.RepositoryIndexLive do

69 152
          </:actions>
70 153
        </.header>
71 154
72
        <div id="repositories" phx-update="stream" class="grid gap-4 md:grid-cols-2">
73
          <.empty
74
            id="repositories-empty"
75
            class="hidden only:block md:col-span-2"
76
            title="No repositories yet"
77
          >
155
        <.cli_panel steps={@cli_steps} clone_url_shape={@clone_url_shape} />
156
157
        <div
158
          id="repositories"
159
          phx-update="stream"
160
          class="divide-y divide-border overflow-hidden rounded-lg border border-border bg-card"
161
        >
162
          <.empty id="repositories-empty" class="hidden only:block" title="No repositories yet">
78 163
            Create an empty repository or import a GitHub repository as a one-time copy.
79 164
          </.empty>
80 165
81
          <.card :for={{id, repository} <- @streams.repositories} id={id}>
82
            <div class="flex items-start justify-between gap-4">
83
              <div class="min-w-0 space-y-2">
84
                <.link
85
                  navigate={~p"/#{repository.namespace.slug}/#{repository.name}"}
86
                  class="font-semibold text-foreground hover:underline"
87
                >
88
                  {repository.namespace.slug}/{repository.name}
89
                </.link>
90
                <p :if={repository.description} class="text-sm text-muted-foreground">
91
                  {repository.description}
92
                </p>
93
              </div>
94
              <.badge variant={status_variant(repository.lifecycle_state)}>
95
                {repository.lifecycle_state}
96
              </.badge>
97
            </div>
98
            <div class="mt-5 flex flex-wrap gap-2 text-sm text-muted-foreground">
99
              <span>{repository.visibility}</span>
100
              <span aria-hidden="true">·</span>
101
              <span>default branch <code>{repository.default_branch}</code></span>
102
            </div>
103
          </.card>
166
          <.repository_row
167
            :for={{id, repository} <- @streams.repositories}
168
            id={id}
169
            repository={repository}
170
          />
104 171
        </div>
105 172
106 173
        <div :if={@repositories_more?} class="flex justify-center">

@@ -113,9 +180,214 @@ defmodule OpenAgentsWeb.RepositoryIndexLive do

113 180
    """
114 181
  end
115 182
116
  defp status_variant("ready"), do: :success
117
  defp status_variant("failed"), do: :danger
118
  defp status_variant(_state), do: :info
183
  attr :id, :string, required: true
184
  attr :repository, :map, required: true
185
186
  defp repository_row(assigns) do
187
    assigns =
188
      assigns
189
      |> assign(:source, import_source(assigns.repository))
190
      |> assign(:stage, provisioning_stage(assigns.repository))
191
192
    ~H"""
193
    <div id={@id} class="space-y-1 px-4 py-3">
194
      <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
195
        <.link
196
          navigate={~p"/#{@repository.namespace.slug}/#{@repository.name}"}
197
          class="truncate font-semibold text-foreground hover:underline"
198
        >
199
          <span class="font-normal text-muted-foreground">{@repository.namespace.slug}/</span>{@repository.name}
200
        </.link>
201
        <.badge variant={:dim}>{@repository.visibility}</.badge>
202
        <.badge :if={@repository.lifecycle_state != "ready"} variant={state_variant(@repository)}>
203
          {@repository.lifecycle_state}
204
        </.badge>
205
        <span class="ml-auto shrink-0 text-xs text-muted-foreground">
206
          Updated {relative_time(@repository.updated_at)}
207
        </span>
208
      </div>
209
210
      <p :if={@repository.description} class="text-sm text-muted-foreground">
211
        {@repository.description}
212
      </p>
213
214
      <div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
215
        <span>
216
          default branch <code class="text-foreground">{@repository.default_branch}</code>
217
        </span>
218
        <span :if={@source} aria-hidden="true">·</span>
219
        <%!-- REPOSITORY-001: the snapshot was copied once and is never
220
        resynchronized, so the row says so rather than implying a mirror. --%>
221
        <span :if={@source} id={"#{@id}-provenance"} data-source={@source}>
222
          Imported once from GitHub, from <code class="text-foreground">{@source}</code>
223
        </span>
224
      </div>
225
226
      <p
227
        :if={@stage}
228
        id={"#{@id}-stage"}
229
        data-state={@stage.state}
230
        class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"
231
      >
232
        <.status_indicator state={@stage.state} label={@stage.label} decorative />
233
        <span class="text-foreground">{@stage.label}</span>
234
        <span :if={@stage.attempt}>attempt {@stage.attempt}</span>
235
        <code :if={@stage.code} class="text-foreground">{@stage.code}</code>
236
      </p>
237
    </div>
238
    """
239
  end
240
241
  attr :steps, :list, required: true
242
  attr :clone_url_shape, :string, required: true
243
244
  # Secondary rather than dismissible: a native `<details>` needs no JavaScript,
245
  # is keyboard operable, reports its own state, and costs one closed line to a
246
  # reader who already has the CLI.
247
  defp cli_panel(assigns) do
248
    ~H"""
249
    <details id="repository-cli" class="overflow-hidden rounded-lg border border-border bg-card">
250
      <summary class="flex cursor-pointer items-center gap-2 px-4 py-3 text-sm font-medium text-foreground">
251
        <.icon name="terminal" /> Connect the CLI
252
      </summary>
253
254
      <div class="space-y-3 border-t border-border px-4 py-3">
255
        <p class="text-sm text-muted-foreground">
256
          The <code class="text-foreground">openagents</code>
257
          command creates, clones, and imports these repositories from a terminal.
258
          Clone URLs are <code class="text-foreground">{@clone_url_shape}</code>.
259
        </p>
260
261
        <ul class="space-y-2">
262
          <li
263
            :for={{step, index} <- Enum.with_index(@steps)}
264
            class="flex items-center gap-2"
265
            id={"repository-cli-step-#{index}"}
266
          >
267
            <code class="min-w-0 flex-1 truncate rounded-md bg-muted px-2 py-1 text-xs text-foreground">
268
              {step.command}
269
            </code>
270
            <span class="hidden shrink-0 text-xs text-muted-foreground sm:inline">
271
              {step.note}
272
            </span>
273
            <.copy_button id={"repository-cli-copy-#{index}"} text={step.command} label="Copy" />
274
          </li>
275
        </ul>
276
      </div>
277
    </details>
278
    """
279
  end
280
281
  defp state_variant(%{lifecycle_state: "failed"}), do: :danger
282
  defp state_variant(_repository), do: :info
283
284
  # The GitHub repository this one was copied from, or nil when it was created
285
  # empty. Only a `github_import` repository has a source to state.
286
  defp import_source(%{provisioning_kind: "github_import"} = repository) do
287
    case receipt(repository.repository_import) do
288
      %{source_full_name: source} when is_binary(source) -> source
289
      _absent -> nil
290
    end
291
  end
292
293
  defp import_source(_repository), do: nil
294
295
  # What the repository is doing, read from the durable receipts rather than
296
  # from the lifecycle word alone.
297
  defp provisioning_stage(%{lifecycle_state: "failed"} = repository) do
298
    %{
299
      state: "failed",
300
      label: "Provisioning failed",
301
      attempt: attempt_count(repository),
302
      code:
303
        repository.provision_error_code || import_error_code(repository) || "provisioning_failed"
304
    }
305
  end
306
307
  defp provisioning_stage(%{lifecycle_state: "provisioning"} = repository) do
308
    %{
309
      state: "running",
310
      label: stage_label(repository),
311
      attempt: attempt_count(repository),
312
      code: import_error_code(repository) || outbox_error_code(repository)
313
    }
314
  end
315
316
  defp provisioning_stage(_repository), do: nil
317
318
  defp stage_label(repository) do
319
    case {outbox_state(repository), repository.provisioning_kind} do
320
      {"running", "github_import"} -> import_label(repository)
321
      {"running", _kind} -> "Creating repository storage"
322
      {"completed", _kind} -> "Finishing"
323
      {"failed", _kind} -> "Waiting to retry"
324
      {_pending_or_absent, "github_import"} -> "Queued to copy from GitHub"
325
      {_pending_or_absent, _kind} -> "Queued"
326
    end
327
  end
328
329
  defp import_label(repository) do
330
    case import_state(repository) do
331
      "running" -> "Copying the GitHub snapshot"
332
      "completed" -> "Storing the copied snapshot"
333
      "failed" -> "Waiting to retry the copy"
334
      _pending_or_absent -> "Starting the copy from GitHub"
335
    end
336
  end
337
338
  defp attempt_count(repository) do
339
    case receipt(repository.provisioning_outbox) do
340
      %{attempt_count: count} when is_integer(count) and count > 1 -> count
341
      _first_or_absent -> nil
342
    end
343
  end
344
345
  defp outbox_state(repository) do
346
    case receipt(repository.provisioning_outbox) do
347
      %{state: state} -> state
348
      nil -> nil
349
    end
350
  end
351
352
  defp outbox_error_code(repository) do
353
    case receipt(repository.provisioning_outbox) do
354
      %{error_code: code} -> code
355
      nil -> nil
356
    end
357
  end
358
359
  defp import_state(repository) do
360
    case receipt(repository.repository_import) do
361
      %{state: state} -> state
362
      nil -> nil
363
    end
364
  end
365
366
  defp import_error_code(repository) do
367
    case receipt(repository.repository_import) do
368
      %{error_code: code} -> code
369
      nil -> nil
370
    end
371
  end
372
373
  defp receipt(%Ecto.Association.NotLoaded{}), do: nil
374
  defp receipt(receipt), do: receipt
375
376
  # Coarse on purpose: a list wants "roughly when", not arithmetic.
377
  defp relative_time(nil), do: "recently"
378
379
  defp relative_time(%DateTime{} = at) do
380
    case DateTime.diff(DateTime.utc_now(), at, :second) do
381
      s when s < 60 -> "just now"
382
      s when s < 3_600 -> "#{div(s, 60)}m ago"
383
      s when s < 86_400 -> "#{div(s, 3_600)}h ago"
384
      s when s < 2_592_000 -> "#{div(s, 86_400)}d ago"
385
      s -> "#{div(s, 2_592_000)}mo ago"
386
    end
387
  end
388
389
  defp relative_time(%NaiveDateTime{} = at),
390
    do: at |> DateTime.from_naive!("Etc/UTC") |> relative_time()
119 391
120 392
  defp cursor(nil), do: nil
121 393
test/openagents/repositories/provisioner_test.exs modified +59

@@ -77,6 +77,48 @@ defmodule OpenAgents.Repositories.ProvisionerTest do

77 77
             "refs/heads/trunk"
78 78
  end
79 79
80
  test "each provisioning transition is announced on the repository's own topic" do
81
    user = repository_user_fixture("provisioner-announce-owner")
82
83
    assert {:ok, repository, :created} =
84
             Repositories.create_user_repository(user, %{name: "announced"}, "announce-key")
85
86
    :ok = Repositories.subscribe_provisioning(repository.id)
87
    other = Ecto.UUID.generate()
88
    :ok = Repositories.subscribe_provisioning(other)
89
90
    provisioner = start_supervised!({Provisioner, name: nil, poll_interval_ms: 60_000})
91
    assert {:ok, 1} = Provisioner.drain(provisioner)
92
93
    # The claim and the completion, in that order: a browser sees "queued"
94
    # become "running" and then "ready" without asking.
95
    assert_receive {:repository_provisioning, id}, 1_000
96
    assert id == repository.id
97
    assert_receive {:repository_provisioning, id}, 1_000
98
    assert id == repository.id
99
    refute_received {:repository_provisioning, ^other}
100
101
    assert OpenAgents.Repo.get!(Repository, repository.id).lifecycle_state == "ready"
102
  end
103
104
  test "a failing repository announces its failure too" do
105
    user = repository_user_fixture("provisioner-announce-failure")
106
107
    assert {:ok, repository, :created} =
108
             Repositories.create_user_repository(user, %{name: "announced-failure"}, "announce-2")
109
110
    :ok = Repositories.subscribe_provisioning(repository.id)
111
112
    assert :processed = Provisioner.run_once(fn _work -> {:error, :fixture_failure} end)
113
114
    assert_receive {:repository_provisioning, id}, 1_000
115
    assert id == repository.id
116
    assert_receive {:repository_provisioning, id}, 1_000
117
    assert id == repository.id
118
119
    assert OpenAgents.Repo.get!(Repository, repository.id).lifecycle_state == "failed"
120
  end
121
80 122
  test "a stale running lease is reclaimed and an injected failure stays bounded" do
81 123
    user = repository_user_fixture("provisioner-recovery-owner")
82 124

@@ -145,11 +187,18 @@ defmodule OpenAgents.Repositories.ProvisionerTest do

145 187
               "import-provision-key"
146 188
             )
147 189
190
    :ok = Repositories.subscribe_provisioning(repository.id)
191
148 192
    assert :processed =
149 193
             Provisioner.run_once(fn work ->
150 194
               Importer.import(work.repository, source_url: source)
151 195
             end)
152 196
197
    # The outbox claim, the import going running, the import completing, and
198
    # the provisioning completing. Each is a durable transition, and each is
199
    # what a watching browser renders as the next stage.
200
    assert_announcements(repository.id, 4)
201
153 202
    completed_import =
154 203
      OpenAgents.Repo.get!(OpenAgents.Repositories.RepositoryImport, repository_import.id)
155 204

@@ -279,6 +328,16 @@ defmodule OpenAgents.Repositories.ProvisionerTest do

279 328
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
280 329
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
281 330
331
  # Waits for exactly `expected` announcements about one repository, and for no
332
  # more than that.
333
  defp assert_announcements(repository_id, expected) do
334
    Enum.each(1..expected, fn _ ->
335
      assert_receive {:repository_provisioning, ^repository_id}, 1_000
336
    end)
337
338
    refute_receive {:repository_provisioning, ^repository_id}, 50
339
  end
340
282 341
  defp audit_types(repository_id) do
283 342
    AuditEvent
284 343
    |> where([event], event.repository_id == ^repository_id)
test/openagents_web/live/code_live_test.exs modified +64

@@ -250,6 +250,70 @@ defmodule OpenAgentsWeb.CodeLiveTest do

250 250
251 251
      assert repository.lifecycle_state == "provisioning"
252 252
    end
253
254
    test "an imported repository states one-time GitHub provenance", %{conn: conn} do
255
      owner = github_user("import-provenance-owner", "import-provenance-owner")
256
257
      assert {:ok, repository, :created} =
258
               OpenAgents.Repositories.create_user_repository(
259
                 owner,
260
                 %{name: "copied-repository", visibility: "private"},
261
                 "import-provenance-repository"
262
               )
263
264
      head = String.duplicate("b", 40)
265
266
      repository
267
      |> Ecto.Changeset.change(provisioning_kind: "github_import")
268
      |> Repo.update!()
269
270
      %OpenAgents.Repositories.RepositoryImport{}
271
      |> OpenAgents.Repositories.RepositoryImport.changeset(repository.id, %{
272
        source_repository_id: 4242,
273
        source_owner_id: 99,
274
        source_full_name: "acme/source-project",
275
        source_default_branch: "main",
276
        source_ref_digest: String.duplicate("a", 64),
277
        source_head_sha: head,
278
        source_refs: %{"refs/heads/main" => head}
279
      })
280
      |> Repo.insert!()
281
      |> OpenAgents.Repositories.RepositoryImport.transition_changeset(%{
282
        state: "completed",
283
        attempt_count: 1,
284
        completed_at: DateTime.utc_now()
285
      })
286
      |> Repo.update!()
287
288
      member_conn = Plug.Test.init_test_session(conn, %{"user_id" => owner.id})
289
      {:ok, view, html} = live(member_conn, "/import-provenance-owner/copied-repository")
290
291
      assert has_element?(view, "#repo-import-provenance")
292
      assert html =~ "Imported once from GitHub"
293
      assert html =~ "acme/source-project"
294
      assert html =~ String.slice(head, 0, 12)
295
296
      # REPOSITORY-001: OpenAgents owns the snapshot; nothing keeps it in step
297
      # with GitHub, so the page must never claim otherwise.
298
      refute html =~ "mirror"
299
      refute html =~ "Synced"
300
    end
301
302
    test "a repository created empty shows no import provenance", %{conn: conn} do
303
      owner = github_user("no-provenance-owner", "no-provenance-owner")
304
305
      assert {:ok, _repository, :created} =
306
               OpenAgents.Repositories.create_user_repository(
307
                 owner,
308
                 %{name: "plain-repository", visibility: "private"},
309
                 "no-provenance-repository"
310
               )
311
312
      member_conn = Plug.Test.init_test_session(conn, %{"user_id" => owner.id})
313
      {:ok, view, _html} = live(member_conn, "/no-provenance-owner/plain-repository")
314
315
      refute has_element?(view, "#repo-import-provenance")
316
    end
253 317
  end
254 318
255 319
  describe "/code/:repo/blob/:ref/*path" do
test/openagents_web/live/repository_live_test.exs modified +132 -1

@@ -4,7 +4,7 @@ defmodule OpenAgentsWeb.RepositoryLiveTest do

4 4
  import Phoenix.LiveViewTest
5 5
6 6
  alias OpenAgents.{Accounts, Repo, Repositories}
7
  alias OpenAgents.Repositories.RepositoryImport
7
  alias OpenAgents.Repositories.{ProvisioningOutbox, RepositoryImport}
8 8
9 9
  setup {Req.Test, :verify_on_exit!}
10 10

@@ -63,6 +63,98 @@ defmodule OpenAgentsWeb.RepositoryLiveTest do

63 63
    refute has_element?(view, "#repositories-load-more")
64 64
  end
65 65
66
  test "the application sidebar reaches the repository index", %{conn: conn} do
67
    user = github_user("repository-live-sidebar", "sidebar-owner")
68
    {:ok, view, _html} = live(log_in(conn, user), ~p"/repositories")
69
70
    assert has_element?(view, ~s(#sidebar .sidebar-nav a[href="/repositories"]))
71
  end
72
73
  test "the index states one-time GitHub provenance and the current stage", %{conn: conn} do
74
    user = github_user("repository-live-provenance", "provenance-owner")
75
76
    assert {:ok, repository, :created} =
77
             Repositories.create_user_repository(
78
               user,
79
               %{name: "copied-project"},
80
               "provenance-copied"
81
             )
82
83
    github_import!(repository, %{state: "running", attempt_count: 1})
84
    claim_outbox!(repository)
85
86
    {:ok, view, _html} = live(log_in(conn, user), ~p"/repositories")
87
88
    row = "#repositories-#{repository.id}"
89
90
    assert has_element?(view, ~s(#{row}-provenance[data-source="acme/source-project"]))
91
    assert has_element?(view, ~s(#{row}-stage[data-state="running"]))
92
    assert render(view) =~ "Copying the GitHub snapshot"
93
94
    # REPOSITORY-001: never labelled as kept in step with the source.
95
    refute render(view) =~ "mirror"
96
    refute render(view) =~ "Synced"
97
  end
98
99
  test "the index names the error code of a failed repository", %{conn: conn} do
100
    user = github_user("repository-live-failure", "failure-owner")
101
102
    assert {:ok, repository, :created} =
103
             Repositories.create_user_repository(
104
               user,
105
               %{name: "broken-project"},
106
               "failure-broken"
107
             )
108
109
    repository
110
    |> Ecto.Changeset.change(
111
      lifecycle_state: "failed",
112
      provision_error_code: "provisioning_failed"
113
    )
114
    |> Repo.update!()
115
116
    {:ok, view, _html} = live(log_in(conn, user), ~p"/repositories")
117
118
    assert has_element?(view, ~s(#repositories-#{repository.id}-stage[data-state="failed"]))
119
    assert render(view) =~ "provisioning_failed"
120
  end
121
122
  test "the index follows a repository to ready without a reload", %{conn: conn} do
123
    user = github_user("repository-live-progress", "progress-owner")
124
125
    assert {:ok, repository, :created} =
126
             Repositories.create_user_repository(
127
               user,
128
               %{name: "watched-project"},
129
               "progress-watched"
130
             )
131
132
    {:ok, view, _html} = live(log_in(conn, user), ~p"/repositories")
133
134
    assert has_element?(view, ~s(#repositories-#{repository.id}-stage[data-state="running"]))
135
136
    repository
137
    |> Ecto.Changeset.change(lifecycle_state: "ready", ready_at: DateTime.utc_now())
138
    |> Repo.update!()
139
140
    :ok = Repositories.broadcast_provisioning(repository.id)
141
142
    refute has_element?(view, "#repositories-#{repository.id}-stage")
143
    assert has_element?(view, "#repositories-#{repository.id}")
144
  end
145
146
  test "the index offers the real CLI commands", %{conn: conn} do
147
    user = github_user("repository-live-cli", "cli-owner")
148
    {:ok, view, html} = live(log_in(conn, user), ~p"/repositories")
149
150
    assert has_element?(view, "#repository-cli")
151
    assert has_element?(view, "#repository-cli-copy-0")
152
    assert html =~ "npm i -g @openagentsinc/cli"
153
    assert html =~ "openagents auth login"
154
    assert html =~ "openagents auth setup-git --local"
155
    assert html =~ "/git/&lt;owner&gt;/&lt;name&gt;.git"
156
  end
157
66 158
  test "new repository defaults private and normalizes its name", %{conn: conn} do
67 159
    user = github_user("repository-live-create", "create-owner")
68 160
    {:ok, view, _html} = live(log_in(conn, user), ~p"/repositories/new")

@@ -126,6 +218,45 @@ defmodule OpenAgentsWeb.RepositoryLiveTest do

126 218
127 219
  defp log_in(conn, user), do: Plug.Test.init_test_session(conn, %{"user_id" => user.id})
128 220
221
  # A repository copied from GitHub, written through the same receipts the
222
  # importer writes, so the index reads the durable rows rather than a fixture
223
  # shape that only exists in tests.
224
  defp github_import!(repository, transition) do
225
    head = String.duplicate("b", 40)
226
227
    repository
228
    |> Ecto.Changeset.change(provisioning_kind: "github_import")
229
    |> Repo.update!()
230
231
    %RepositoryImport{}
232
    |> RepositoryImport.changeset(repository.id, %{
233
      source_repository_id: 4242,
234
      source_owner_id: 99,
235
      source_full_name: "acme/source-project",
236
      source_default_branch: "main",
237
      source_ref_digest: String.duplicate("a", 64),
238
      source_head_sha: head,
239
      source_refs: %{"refs/heads/main" => head}
240
    })
241
    |> Repo.insert!()
242
    |> RepositoryImport.transition_changeset(transition)
243
    |> Repo.update!()
244
  end
245
246
  defp claim_outbox!(repository) do
247
    now = DateTime.utc_now()
248
249
    ProvisioningOutbox
250
    |> Repo.get_by!(repository_id: repository.id)
251
    |> Ecto.Changeset.change(
252
      operation: "github_import",
253
      state: "running",
254
      attempt_count: 1,
255
      claimed_at: now
256
    )
257
    |> Repo.update!()
258
  end
259
129 260
  defp repository_payload(user, main_sha) do
130 261
    %{
131 262
      "id" => 901,

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