Fix live repository discovery and cold starts

eda094c6ae9f · AtlantisPleb · · parent c91327d60c52

Fix live repository discovery and cold starts

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 assets/css/openagents.css
  • modified lib/openagents/forge/browse.ex
  • modified lib/openagents/forge/sync.ex
  • modified lib/openagents/repositories.ex
  • modified lib/openagents/repositories/importer.ex
  • modified lib/openagents_web/live/code_repo_live.ex
  • modified lib/openagents_web/live/repository_index_live.ex
  • modified lib/openagents_web/plugs/content_security_policy.ex
  • modified test/openagents/forge/browse_test.exs
  • modified test/openagents/forge/sync_test.exs
  • modified test/openagents_web/home_controller_test.exs
  • modified test/openagents_web/live/code_live_test.exs
  • modified test/openagents_web/live/repository_live_test.exs

Diff

13 files changed, +388 -80

assets/css/openagents.css modified +45

@@ -5286,6 +5286,51 @@

5286 5286
    font-weight: 400;
5287 5287
  }
5288 5288
5289
  .repo-import-provenance {
5290
    display: flex;
5291
    flex-direction: column;
5292
    gap: 12px;
5293
    padding-block-start: 18px;
5294
    border-block-start: 1px solid var(--line);
5295
  }
5296
5297
  .repo-import-provenance h2 {
5298
    color: var(--text-primary);
5299
    font-size: 0.875rem;
5300
    font-weight: 600;
5301
  }
5302
5303
  .repo-import-provenance dl {
5304
    display: flex;
5305
    flex-direction: column;
5306
    gap: 8px;
5307
    margin: 0;
5308
  }
5309
5310
  .repo-import-provenance dl > div {
5311
    display: grid;
5312
    grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
5313
    gap: 10px;
5314
    color: var(--text-muted);
5315
    font-size: 0.75rem;
5316
  }
5317
5318
  .repo-import-provenance dt {
5319
    color: var(--text-dim);
5320
  }
5321
5322
  .repo-import-provenance dd {
5323
    min-width: 0;
5324
    overflow-wrap: anywhere;
5325
  }
5326
5327
  .repo-import-provenance p {
5328
    margin: 0;
5329
    color: var(--text-dim);
5330
    font-size: 0.75rem;
5331
    line-height: 1.5;
5332
  }
5333
5289 5334
  /* A wrapped cluster rather than a column: the group reads as one fact about
5290 5335
     the repository, not as a list to be gone through name by name. Each face
5291 5336
     still carries its own accessible name. */
lib/openagents/forge/browse.ex modified +87 -9

@@ -42,11 +42,7 @@ defmodule OpenAgents.Forge.Browse do

42 42
  def resolve_commit(repo, ref) do
43 43
    with :ok <- check(repo, ref) do
44 44
      freshen(repo)
45
46
      case git(repo, ["rev-parse", "--verify", "--end-of-options", ref <> "^{commit}"]) do
47
        {output, 0} -> {:ok, String.trim(output)}
48
        _ -> {:error, :not_found}
49
      end
45
      resolve_commit_from_cache(repo, ref)
50 46
    end
51 47
  end
52 48

@@ -75,6 +71,29 @@ defmodule OpenAgents.Forge.Browse do

75 71
  @doc "The default branch head, if the repo has one."
76 72
  def head(repo) do
77 73
    _ = freshen(repo)
74
    head_from_cache(repo)
75
  end
76
77
  @doc "Read all repository-home data after one cache-freshness check."
78
  def overview(repo, log_limit \\ 20) do
79
    _ = freshen(repo)
80
81
    head =
82
      case head_from_cache(repo) do
83
        {:ok, sha} -> sha
84
        _ -> nil
85
      end
86
87
    %{
88
      head: head,
89
      readme: overview_readme(repo, head),
90
      commits: overview_commits(repo, head, log_limit),
91
      entries: overview_entries(repo, head),
92
      refs: if(head, do: refs_from_cache(repo), else: [])
93
    }
94
  end
95
96
  defp head_from_cache(repo) do
78 97
    refs = Repos.refs(storage_key(repo))
79 98
80 99
    case Map.fetch(refs, "refs/heads/#{default_branch(repo)}") do

@@ -85,7 +104,9 @@ defmodule OpenAgents.Forge.Browse do

85 104
  end
86 105
87 106
  @doc "Branches and tags as `[%{name, sha, kind}]`, bounded."
88
  def refs(repo) do
107
  def refs(repo), do: refs_from_cache(repo)
108
109
  defp refs_from_cache(repo) do
89 110
    repo
90 111
    |> storage_key()
91 112
    |> Repos.refs()

@@ -100,6 +121,42 @@ defmodule OpenAgents.Forge.Browse do

100 121
    |> Enum.take(@list_cap)
101 122
  end
102 123
124
  defp resolve_commit_from_cache(repo, ref) do
125
    case git(repo, ["rev-parse", "--verify", "--end-of-options", ref <> "^{commit}"]) do
126
      {output, 0} -> {:ok, String.trim(output)}
127
      _ -> {:error, :not_found}
128
    end
129
  end
130
131
  defp overview_readme(_repo, nil), do: nil
132
133
  defp overview_readme(repo, head) do
134
    Enum.find_value(["README.md", "README"], fn name ->
135
      case blob_from_cache(repo, head, name) do
136
        {:ok, blob} -> %{name: name, blob: blob}
137
        _ -> nil
138
      end
139
    end)
140
  end
141
142
  defp overview_commits(_repo, nil, _limit), do: []
143
144
  defp overview_commits(repo, head, limit) do
145
    case log_from_cache(repo, head, limit) do
146
      {:ok, commits} -> commits
147
      _ -> []
148
    end
149
  end
150
151
  defp overview_entries(_repo, nil), do: []
152
153
  defp overview_entries(repo, head) do
154
    case tree_from_cache(repo, head, "") do
155
      {:ok, entries} -> entries
156
      _ -> []
157
    end
158
  end
159
103 160
  @doc """
104 161
  One commit's bounded metadata: message, author name, committed-at, parents,
105 162
  and its provenance trailers (`Co-Authored-By`, `Claude-Session`,

@@ -177,8 +234,15 @@ defmodule OpenAgents.Forge.Browse do

177 234
178 235
  @doc "Directory listing at `ref`/`path` as `[%{name, kind, size}]`, bounded."
179 236
  def tree(repo, ref, path \\ "") do
180
    with {:ok, full} <- resolve_commit(repo, ref),
237
    with :ok <- check(repo, ref),
181 238
         :ok <- check_path(path, allow_empty: true) do
239
      _ = freshen(repo)
240
      tree_from_cache(repo, ref, path)
241
    end
242
  end
243
244
  defp tree_from_cache(repo, ref, path) do
245
    with {:ok, full} <- resolve_commit_from_cache(repo, ref) do
182 246
      spec = if path == "", do: full, else: full <> ":" <> path
183 247
184 248
      case git(repo, ["ls-tree", "-l", "--end-of-options", spec]) do

@@ -210,8 +274,15 @@ defmodule OpenAgents.Forge.Browse do

210 274
  `{:ok, %{content, truncated, binary, size}}`.
211 275
  """
212 276
  def blob(repo, ref, path) do
213
    with {:ok, full} <- resolve_commit(repo, ref),
277
    with :ok <- check(repo, ref),
214 278
         :ok <- check_path(path) do
279
      _ = freshen(repo)
280
      blob_from_cache(repo, ref, path)
281
    end
282
  end
283
284
  defp blob_from_cache(repo, ref, path) do
285
    with {:ok, full} <- resolve_commit_from_cache(repo, ref) do
215 286
      spec = full <> ":" <> path
216 287
217 288
      with {size_out, 0} <- git(repo, ["cat-file", "-s", spec]),

@@ -244,9 +315,16 @@ defmodule OpenAgents.Forge.Browse do

244 315
245 316
  @doc "Bounded commit list from `ref` as commit maps (no diffs)."
246 317
  def log(repo, ref, limit \\ 30) do
318
    with :ok <- check(repo, ref) do
319
      _ = freshen(repo)
320
      log_from_cache(repo, ref, limit)
321
    end
322
  end
323
324
  defp log_from_cache(repo, ref, limit) do
247 325
    limit = min(limit, 100)
248 326
249
    with {:ok, full} <- resolve_commit(repo, ref) do
327
    with {:ok, full} <- resolve_commit_from_cache(repo, ref) do
250 328
      format = "%H%x00%an%x00%cI%x00%s%x01"
251 329
252 330
      case git(repo, [
lib/openagents/forge/sync.ex modified +49

@@ -9,8 +9,11 @@ defmodule OpenAgents.Forge.Sync do

9 9
10 10
  require Logger
11 11
12
  alias OpenAgents.Cluster
12 13
  alias OpenAgents.Forge.{GitHTTP, Repos, WAL}
13 14
15
  @default_cluster_warm_timeout_ms 10 * 60 * 1_000
16
14 17
  @doc """
15 18
  Bring the local bare repo up to the WAL. Returns `:ok` (fresh, replayed,
16 19
  or nothing pushed yet) — read paths degrade to serving the local cache if

@@ -33,6 +36,37 @@ defmodule OpenAgents.Forge.Sync do

33 36
    end
34 37
  end
35 38
39
  @doc "Bring every connected node's disposable bare-repository cache up to date."
40
  def ensure_cluster_fresh(repo, default_branch \\ "main", options \\ []) do
41
    members = Keyword.get(options, :members, &Cluster.members/0).() |> Enum.uniq()
42
    rpc = Keyword.get(options, :rpc, &:erpc.call/5)
43
44
    timeout_ms =
45
      Keyword.get(
46
        options,
47
        :timeout_ms,
48
        Application.get_env(
49
          :openagents,
50
          :repository_cluster_warm_timeout_ms,
51
          @default_cluster_warm_timeout_ms
52
        )
53
      )
54
55
    members
56
    |> Task.async_stream(
57
      fn target -> warm_node(target, repo, default_branch, rpc, timeout_ms) end,
58
      ordered: false,
59
      timeout: timeout_ms + 1_000,
60
      on_timeout: :kill_task,
61
      max_concurrency: max(1, length(members))
62
    )
63
    |> Enum.reduce_while(:ok, fn
64
      {:ok, :ok}, :ok -> {:cont, :ok}
65
      {:ok, {:error, reason}}, :ok -> {:halt, {:error, reason}}
66
      {:exit, reason}, :ok -> {:halt, {:error, reason}}
67
    end)
68
  end
69
36 70
  @doc "Replay WAL entries the local repo has not applied. Used by reads and boot."
37 71
  def replay_missing(repo, index, default_branch \\ "main") do
38 72
    Repos.ensure_repo!(repo, default_branch)

@@ -128,4 +162,19 @@ defmodule OpenAgents.Forge.Sync do

128 162
  defp run_receive_pack(path, payload) do
129 163
    GitHTTP.run_git_service("receive-pack", [path], payload, nil)
130 164
  end
165
166
  defp warm_node(target, repo, default_branch, _rpc, _timeout_ms) when target == node(),
167
    do: ensure_fresh(repo, default_branch)
168
169
  defp warm_node(target, repo, default_branch, rpc, timeout_ms) do
170
    case rpc.(target, __MODULE__, :ensure_fresh, [repo, default_branch], timeout_ms) do
171
      :ok -> :ok
172
      {:error, reason} -> {:error, reason}
173
      _other -> {:error, :invalid_cluster_warm_result}
174
    end
175
  rescue
176
    _error -> {:error, :cluster_warm_exception}
177
  catch
178
    :exit, reason -> {:error, reason}
179
  end
131 180
end
lib/openagents/repositories.ex modified +29 -2

@@ -291,6 +291,8 @@ defmodule OpenAgents.Repositories do

291 291
292 292
      case result do
293 293
        {:ok, deleted} = success ->
294
          broadcast_repository_change(deleted.id)
295
294 296
          Analytics.capture("repository_deleted", Analytics.distinct_id(user), %{
295 297
            "repository_id" => deleted.id,
296 298
            "provisioning_kind" => deleted.provisioning_kind,

@@ -474,6 +476,10 @@ defmodule OpenAgents.Repositories do

474 476
  def subscribe_provisioning(repository_id) when is_binary(repository_id),
475 477
    do: Phoenix.PubSub.subscribe(OpenAgents.PubSub, provisioning_topic(repository_id))
476 478
479
  @doc "Subscribes a repository index to repository creation and deletion events."
480
  def subscribe_repository_changes,
481
    do: Phoenix.PubSub.subscribe(OpenAgents.PubSub, repository_changes_topic())
482
477 483
  @doc "Stops the caller hearing about one repository, once it has settled."
478 484
  def unsubscribe_provisioning(repository_id) when is_binary(repository_id),
479 485
    do: Phoenix.PubSub.unsubscribe(OpenAgents.PubSub, provisioning_topic(repository_id))

@@ -493,7 +499,17 @@ defmodule OpenAgents.Repositories do

493 499
    )
494 500
  end
495 501
502
  @doc "Announces that a repository entered or left the visible repository collection."
503
  def broadcast_repository_change(repository_id) when is_binary(repository_id) do
504
    Phoenix.PubSub.broadcast(
505
      OpenAgents.PubSub,
506
      repository_changes_topic(),
507
      {:repository_changed, repository_id}
508
    )
509
  end
510
496 511
  defp provisioning_topic(repository_id), do: "repository:" <> repository_id
512
  defp repository_changes_topic, do: "repositories:changes"
497 513
498 514
  defp apply_namespace_filter(query, nil), do: query
499 515

@@ -686,8 +702,19 @@ defmodule OpenAgents.Repositories do

686 702
      end)
687 703
688 704
    case result do
689
      {:ok, value} -> value
690
      {:error, reason} -> {:error, reason}
705
      {:ok, {:ok, %Repository{} = repository, :created} = value} ->
706
        broadcast_repository_change(repository.id)
707
        value
708
709
      {:ok, {:ok, %Repository{} = repository, %RepositoryImport{}, :created} = value} ->
710
        broadcast_repository_change(repository.id)
711
        value
712
713
      {:ok, value} ->
714
        value
715
716
      {:error, reason} ->
717
        {:error, reason}
691 718
    end
692 719
  rescue
693 720
    error in Ecto.InvalidChangesetError -> {:error, error.changeset}
lib/openagents/repositories/importer.ex modified +1 -1

@@ -165,7 +165,7 @@ defmodule OpenAgents.Repositories.Importer do

165 165
           end),
166 166
         :ok <-
167 167
           import_stage(repository, repository_import, "materialize_cache", fn ->
168
             Sync.ensure_fresh(repository.storage_key, repository.default_branch)
168
             Sync.ensure_cluster_fresh(repository.storage_key, repository.default_branch)
169 169
           end) do
170 170
      :ok
171 171
    end
lib/openagents_web/live/code_repo_live.ex modified +47 -63

@@ -24,31 +24,12 @@ defmodule OpenAgentsWeb.CodeRepoLive do

24 24
      raise OpenAgentsWeb.PublicNotFoundError
25 25
    end
26 26
27
    head =
28
      case Browse.head(repository) do
29
        {:ok, sha} -> sha
30
        _ -> nil
31
      end
32
33
    readme =
34
      case head && Browse.readme(repository, head) do
35
        {:ok, name, blob} -> %{name: name, blob: blob}
36
        _ -> nil
37
      end
38
39
    commits =
40
      case head && Browse.log(repository, head, 20) do
41
        {:ok, commits} -> commits
42
        _ -> []
43
      end
44
45
    entries =
46
      case head && Browse.tree(repository, head) do
47
        {:ok, entries} -> entries
48
        _ -> []
49
      end
50
51
    refs = if head, do: Browse.refs(repository), else: []
27
    overview = Browse.overview(repository, 20)
28
    head = overview.head
29
    readme = overview.readme
30
    commits = overview.commits
31
    entries = overview.entries
32
    refs = overview.refs
52 33
53 34
    delete_allowed? =
54 35
      Repositories.membership_role(repository, socket.assigns.current_user) == "owner"

@@ -223,44 +204,6 @@ defmodule OpenAgentsWeb.CodeRepoLive do

223 204
            Error code: <code>{@repository.provision_error_code || "provisioning_failed"}</code>
224 205
          </.alert>
225 206
226
          <%!-- REPOSITORY-001: an import freezes one authorized ref map and
227
          schedules no later synchronization, so this states the source, what
228
          was accepted, and that nothing keeps the two in step. Never labelled
229
          synced or mirrored. --%>
230
          <.card :if={@repository_import} id="repo-import-provenance">
231
            <h2>Imported once from GitHub</h2>
232
            <dl class="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-[auto_1fr]">
233
              <dt class="text-muted-foreground">Source</dt>
234
              <dd><code>{@repository_import.source_full_name}</code></dd>
235
236
              <dt :if={@repository_import.source_head_sha} class="text-muted-foreground">
237
                Accepted snapshot
238
              </dt>
239
              <dd :if={@repository_import.source_head_sha}>
240
                <code>{short(@repository_import.source_head_sha)}</code>
241
              </dd>
242
243
              <dt class="text-muted-foreground">State</dt>
244
              <dd>{@repository_import.state}</dd>
245
246
              <dt :if={@repository_import.completed_at} class="text-muted-foreground">Completed</dt>
247
              <dd :if={@repository_import.completed_at}>
248
                <time datetime={DateTime.to_iso8601(@repository_import.completed_at)}>
249
                  {Calendar.strftime(@repository_import.completed_at, "%Y-%m-%d %H:%M UTC")}
250
                </time>
251
              </dd>
252
253
              <dt :if={@repository_import.error_code} class="text-muted-foreground">Error code</dt>
254
              <dd :if={@repository_import.error_code}>
255
                <code>{@repository_import.error_code}</code>
256
              </dd>
257
            </dl>
258
            <p class="mt-3 text-sm text-muted-foreground">
259
              OpenAgents copied this snapshot once and is now the source of truth for it.
260
              Later commits on GitHub do not appear here.
261
            </p>
262
          </.card>
263
264 207
          <.empty
265 208
            :if={@repository.lifecycle_state == "ready" and is_nil(@head)}
266 209
            id="repo-empty"

@@ -416,6 +359,47 @@ defmodule OpenAgentsWeb.CodeRepoLive do

416 359
                {@tag_count} {if @tag_count == 1, do: "tag", else: "tags"}
417 360
              </:stat>
418 361
            </.repo_about>
362
363
            <%!-- REPOSITORY-001: an import freezes one authorized ref map and
364
            schedules no later synchronization. Keep that provenance beside
365
            the repository metadata instead of interrupting the code tree. --%>
366
            <section
367
              :if={@repository_import}
368
              id="repo-import-provenance"
369
              class="repo-import-provenance"
370
              aria-labelledby="repo-import-provenance-title"
371
            >
372
              <h2 id="repo-import-provenance-title">Imported from GitHub</h2>
373
              <dl>
374
                <div>
375
                  <dt>Source</dt>
376
                  <dd><code>{@repository_import.source_full_name}</code></dd>
377
                </div>
378
                <div :if={@repository_import.source_head_sha}>
379
                  <dt>Snapshot</dt>
380
                  <dd><code>{short(@repository_import.source_head_sha)}</code></dd>
381
                </div>
382
                <div>
383
                  <dt>State</dt>
384
                  <dd>{@repository_import.state}</dd>
385
                </div>
386
                <div :if={@repository_import.completed_at}>
387
                  <dt>Completed</dt>
388
                  <dd>
389
                    <time datetime={DateTime.to_iso8601(@repository_import.completed_at)}>
390
                      {Calendar.strftime(@repository_import.completed_at, "%Y-%m-%d %H:%M UTC")}
391
                    </time>
392
                  </dd>
393
                </div>
394
                <div :if={@repository_import.error_code}>
395
                  <dt>Error code</dt>
396
                  <dd><code>{@repository_import.error_code}</code></dd>
397
                </div>
398
              </dl>
399
              <p>
400
                OpenAgents copied this snapshot once. Later GitHub commits do not appear here.
401
              </p>
402
            </section>
419 403
          </:about>
420 404
        </.repo_view>
421 405
      </main>
lib/openagents_web/live/repository_index_live.ex modified +39 -2

@@ -47,6 +47,14 @@ defmodule OpenAgentsWeb.RepositoryIndexLive do

47 47
        nil
48 48
      )
49 49
50
    socket =
51
      if connected?(socket) do
52
        :ok = Repositories.subscribe_repository_changes()
53
        socket
54
      else
55
        socket
56
      end
57
50 58
    {:ok,
51 59
     socket
52 60
     |> assign(:page_title, "Repositories")

@@ -99,6 +107,27 @@ defmodule OpenAgentsWeb.RepositoryIndexLive do

99 107
    end
100 108
  end
101 109
110
  def handle_info({:repository_changed, repository_id}, socket) do
111
    socket = watch_id(socket, repository_id)
112
113
    case Repositories.get_visible_repository(repository_id, socket.assigns.current_user) do
114
      nil ->
115
        {:noreply,
116
         socket
117
         |> stream_delete_by_dom_id(:repositories, "repositories-#{repository_id}")
118
         |> unwatch(repository_id)}
119
120
      repository ->
121
        socket = stream_insert(socket, :repositories, repository)
122
123
        if repository.lifecycle_state == "provisioning" do
124
          {:noreply, socket}
125
        else
126
          {:noreply, unwatch(socket, repository_id)}
127
        end
128
    end
129
  end
130
102 131
  # Subscribing only to repositories that can still change. A ready repository
103 132
  # never transitions again, and a list of a hundred of them would otherwise
104 133
  # open a hundred topics that never carry a message.

@@ -107,8 +136,7 @@ defmodule OpenAgentsWeb.RepositoryIndexLive do

107 136
      Enum.reduce(repositories, socket, fn repository, socket ->
108 137
        if repository.lifecycle_state == "provisioning" and
109 138
             repository.id not in socket.assigns.watching do
110
          :ok = Repositories.subscribe_provisioning(repository.id)
111
          assign(socket, :watching, MapSet.put(socket.assigns.watching, repository.id))
139
          watch_id(socket, repository.id)
112 140
        else
113 141
          socket
114 142
        end

@@ -118,6 +146,15 @@ defmodule OpenAgentsWeb.RepositoryIndexLive do

118 146
    end
119 147
  end
120 148
149
  defp watch_id(socket, repository_id) do
150
    if connected?(socket) and repository_id not in socket.assigns.watching do
151
      :ok = Repositories.subscribe_provisioning(repository_id)
152
      assign(socket, :watching, MapSet.put(socket.assigns.watching, repository_id))
153
    else
154
      socket
155
    end
156
  end
157
121 158
  defp unwatch(socket, repository_id) do
122 159
    if repository_id in socket.assigns.watching do
123 160
      :ok = Repositories.unsubscribe_provisioning(repository_id)
lib/openagents_web/plugs/content_security_policy.ex modified +3 -1

@@ -33,7 +33,9 @@ defmodule OpenAgentsWeb.Plugs.ContentSecurityPolicy do

33 33
        "frame-ancestors 'none'",
34 34
        "img-src 'self' data: https://avatars.githubusercontent.com",
35 35
        "object-src 'none'",
36
        "script-src 'self' 'nonce-#{nonce}'",
36
        # `posthog-js` is bundled, but it loads optional browser modules from
37
        # PostHog's versioned asset host after it receives remote configuration.
38
        "script-src 'self' 'nonce-#{nonce}' https://us-assets.i.posthog.com",
37 39
        "style-src 'self' 'unsafe-inline'"
38 40
      ],
39 41
      "; "
test/openagents/forge/browse_test.exs modified +11

@@ -215,6 +215,17 @@ defmodule OpenAgents.Forge.BrowseTest do

215 215
    assert oldest.subject == "First commit"
216 216
  end
217 217
218
  test "overview/2 returns repository-home data after one bounded read", %{second: second} do
219
    overview = Browse.overview("openagents.com", 1)
220
221
    assert overview.head == second
222
    assert overview.readme.name == "README.md"
223
    assert overview.readme.blob.content =~ "Fixture readme."
224
    assert [%{sha: ^second}] = overview.commits
225
    assert Enum.map(overview.entries, & &1.name) == ["docs", "README.md", "file.txt"]
226
    assert %{name: "main", sha: second, kind: :branch} in overview.refs
227
  end
228
218 229
  describe "shape gates" do
219 230
    test "valid_ref?/1 rejects flag-shaped, traversing, and range refs" do
220 231
      assert Browse.valid_ref?("main")
test/openagents/forge/sync_test.exs modified +33

@@ -97,6 +97,39 @@ defmodule OpenAgents.Forge.SyncTest do

97 97
    assert Repos.refs("empty-authority") == %{}
98 98
  end
99 99
100
  test "cluster warming materializes the local cache and every connected peer" do
101
    test_process = self()
102
    peer = :"peer@127.0.0.1"
103
104
    rpc = fn target, module, function, arguments, timeout ->
105
      send(test_process, {:cluster_warm_rpc, target, module, function, arguments, timeout})
106
      :ok
107
    end
108
109
    assert :ok =
110
             Sync.ensure_cluster_fresh("cluster-warm", "trunk",
111
               members: fn -> [node(), peer] end,
112
               rpc: rpc,
113
               timeout_ms: 5_000
114
             )
115
116
    assert_received {:cluster_warm_rpc, ^peer, Sync, :ensure_fresh, ["cluster-warm", "trunk"],
117
                     5_000}
118
  end
119
120
  test "cluster warming reports a peer failure" do
121
    peer = :"peer@127.0.0.1"
122
123
    assert {:error, :noconnection} =
124
             Sync.ensure_cluster_fresh("cluster-warm-failure", "main",
125
               members: fn -> [peer] end,
126
               rpc: fn _target, _module, _function, _arguments, _timeout ->
127
                 {:error, :noconnection}
128
               end,
129
               timeout_ms: 5_000
130
             )
131
  end
132
100 133
  defp git!(directory, args) do
101 134
    {output, 0} = System.cmd("git", args, cd: directory, stderr_to_stdout: true)
102 135
    output
test/openagents_web/home_controller_test.exs modified +3

@@ -74,6 +74,9 @@ defmodule OpenAgentsWeb.HomeControllerTest do

74 74
    refute first_nonce == second_nonce
75 75
    refute first_policy =~ ~r/script-src[^;]*'unsafe-inline'/
76 76
77
    assert first_policy =~
78
             "script-src 'self' 'nonce-#{first_nonce}' https://us-assets.i.posthog.com"
79
77 80
    document = first |> html_response(200) |> LazyHTML.from_document()
78 81
79 82
    assert document
test/openagents_web/live/code_live_test.exs modified +2 -2

@@ -292,8 +292,8 @@ defmodule OpenAgentsWeb.CodeLiveTest do

292 292
      member_conn = Plug.Test.init_test_session(conn, %{"user_id" => owner.id})
293 293
      {:ok, view, html} = live(member_conn, "/import-provenance-owner/copied-repository")
294 294
295
      assert has_element?(view, "#repo-import-provenance")
296
      assert html =~ "Imported once from GitHub"
295
      assert has_element?(view, ".repo-view__rail #repo-import-provenance")
296
      assert html =~ "Imported from GitHub"
297 297
      assert html =~ "acme/source-project"
298 298
      assert html =~ String.slice(head, 0, 12)
299 299
test/openagents_web/live/repository_live_test.exs modified +39

@@ -38,6 +38,45 @@ defmodule OpenAgentsWeb.RepositoryLiveTest do

38 38
    assert has_element?(view, "#import-repository")
39 39
  end
40 40
41
  test "repository index discovers a repository created after mount", %{conn: conn} do
42
    user = github_user("repository-live-discovery", "discovery-owner")
43
    {:ok, view, _html} = live(log_in(conn, user), ~p"/repositories")
44
45
    assert {:ok, repository, :created} =
46
             Repositories.create_user_repository(
47
               user,
48
               %{name: "appeared-live", visibility: "private"},
49
               "appeared-live"
50
             )
51
52
    assert has_element?(view, "#repositories-#{repository.id}")
53
    assert has_element?(view, "#repositories-#{repository.id}-stage")
54
  end
55
56
  test "repository index removes a repository deleted after mount", %{conn: conn} do
57
    user = github_user("repository-live-removal", "removal-owner")
58
59
    assert {:ok, repository, :created} =
60
             Repositories.create_user_repository(
61
               user,
62
               %{name: "removed-live", visibility: "private"},
63
               "removed-live"
64
             )
65
66
    {:ok, view, _html} = live(log_in(conn, user), ~p"/repositories")
67
    assert has_element?(view, "#repositories-#{repository.id}")
68
69
    assert {:ok, _deleted} =
70
             Repositories.delete_owned_repository(
71
               "removal-owner",
72
               "removed-live",
73
               user,
74
               surface: "test"
75
             )
76
77
    refute has_element?(view, "#repositories-#{repository.id}")
78
  end
79
41 80
  test "repository index loads bounded pages", %{conn: conn} do
42 81
    user = github_user("repository-live-pagination", "pagination-owner")
43 82

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