Add approved chat workspace publication

e98c9c5065c7 · AtlantisPleb · · parent 72e3e4ab32dc

Add approved chat workspace publication

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 config/config.exs
  • modified config/test.exs
  • modified docs/architecture.md
  • added lib/openagents/repositories/repository_publication.ex
  • modified lib/openagents/tools/execution_context.ex
  • added lib/openagents/tools/publish_changes.ex
  • modified lib/openagents/tools/runner.ex
  • added lib/openagents/tools/workspace_publication.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823010819_create_repository_publications.exs
  • added test/openagents/tools/publish_changes_test.exs

Diff

11 files changed, +885 -1

config/config.exs modified +1

@@ -151,6 +151,7 @@ config :openagents,

151 151
    OpenAgents.Tools.WorkspaceRead,
152 152
    OpenAgents.Tools.WorkspaceWrite,
153 153
    OpenAgents.Tools.WorkspaceEdit,
154
    OpenAgents.Tools.PublishChanges,
154 155
    OpenAgents.Tools.ConversationSearch,
155 156
    OpenAgents.Tools.ConversationRead,
156 157
    OpenAgents.Tools.MemoryList,
config/test.exs modified +1

@@ -115,6 +115,7 @@ config :openagents, :tools, [

115 115
  OpenAgents.Tools.WorkspaceRead,
116 116
  OpenAgents.Tools.WorkspaceWrite,
117 117
  OpenAgents.Tools.WorkspaceEdit,
118
  OpenAgents.Tools.PublishChanges,
118 119
  OpenAgents.Tools.ConversationSearch,
119 120
  OpenAgents.Tools.ConversationRead,
120 121
  OpenAgents.Tools.MemoryList,
docs/architecture.md modified +16

@@ -177,6 +177,22 @@ symbolic links, canonical Forge repositories, and application checkouts. The

177 177
model and API request can select a relative file path, but they cannot supply
178 178
the workspace root or mint mutation authority.
179 179
180
`publish_changes` is the only chat tool that turns those workspace mutations
181
into a Forge ref. The host derives the repository, remote, and opaque
182
`openagents/chat/<run-id>` branch from the authenticated workspace. The caller
183
supplies only a commit message and, optionally, the workspace digest it
184
observed. Publication requires `repository.write`, current repository
185
membership, and an exact approval receipt. It never writes the default branch.
186
187
Each admitted tool call creates one `repository_publications` ledger row before
188
Git or network effects begin. The row records the argument and workspace
189
digests, expected previous branch object ID, published object ID, state, WAL
190
sequence, and exact result. A retry with the same tool call ID returns the
191
stored result. A retry with different arguments fails, and a branch that no
192
longer matches the recorded lease fails without overwriting the new tip. Forge
193
acceptance is complete only after the publication resolves the matching push
194
receipt from the durable WAL.
195
180 196
Bearer clients use the same account chat entry point through a personal API
181 197
token with `chat:account` scope. Forge mutations continue to require
182 198
`forge:write`; one scope does not imply the other. The authenticated account,
lib/openagents/repositories/repository_publication.ex added +75

@@ -0,0 +1,75 @@

1
defmodule OpenAgents.Repositories.RepositoryPublication do
2
  @moduledoc "Records one idempotent publication from a chat workspace to Forge."
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @primary_key {:id, :binary_id, autogenerate: true}
8
  @foreign_key_type :binary_id
9
  @timestamps_opts [type: :utc_datetime_usec]
10
  @states ~w(requested committing pushing accepted uncertain failed nothing_to_publish)
11
12
  schema "repository_publications" do
13
    belongs_to :repository, OpenAgents.Repositories.Repository
14
    belongs_to :owner_user, OpenAgents.Accounts.User
15
    field :conversation_id, :binary_id
16
    field :tool_call_id, :string
17
    field :workspace_ref, :string
18
    field :idempotency_key, :string
19
    field :argument_digest, :string
20
    field :message, :string
21
    field :expected_workspace_digest, :string
22
    field :observed_workspace_digest, :string
23
    field :branch, :string
24
    field :source_oid, :string
25
    field :expected_previous_oid, :string
26
    field :published_oid, :string
27
    field :state, :string, default: "requested"
28
    field :wal_seq, :integer
29
    field :result, :map
30
    field :error_code, :string
31
    timestamps()
32
  end
33
34
  def changeset(publication, attrs) do
35
    publication
36
    |> cast(attrs, [
37
      :repository_id,
38
      :owner_user_id,
39
      :conversation_id,
40
      :tool_call_id,
41
      :workspace_ref,
42
      :idempotency_key,
43
      :argument_digest,
44
      :message,
45
      :expected_workspace_digest,
46
      :observed_workspace_digest,
47
      :branch,
48
      :source_oid,
49
      :expected_previous_oid,
50
      :published_oid,
51
      :state,
52
      :wal_seq,
53
      :result,
54
      :error_code
55
    ])
56
    |> validate_required([
57
      :repository_id,
58
      :owner_user_id,
59
      :workspace_ref,
60
      :idempotency_key,
61
      :argument_digest,
62
      :message,
63
      :branch,
64
      :state
65
    ])
66
    |> validate_inclusion(:state, @states)
67
    |> validate_length(:message, min: 1, max: 2_000)
68
    |> validate_length(:idempotency_key, is: 64)
69
    |> validate_length(:argument_digest, is: 64)
70
    |> unique_constraint(:idempotency_key)
71
    |> foreign_key_constraint(:repository_id)
72
    |> foreign_key_constraint(:owner_user_id)
73
    |> check_constraint(:state, name: :repository_publications_state_check)
74
  end
75
end
lib/openagents/tools/execution_context.ex modified +2

@@ -9,6 +9,7 @@ defmodule OpenAgents.Tools.ExecutionContext do

9 9
                job_ref: nil,
10 10
                conversation_id: nil,
11 11
                current_user_message_id: nil,
12
                current_tool_call_id: nil,
12 13
                owner_user_id: nil,
13 14
                owner_visitor_id: nil,
14 15
                workspace: nil,

@@ -27,6 +28,7 @@ defmodule OpenAgents.Tools.ExecutionContext do

27 28
          job_ref: String.t() | nil,
28 29
          conversation_id: Ecto.UUID.t() | nil,
29 30
          current_user_message_id: Ecto.UUID.t() | nil,
31
          current_tool_call_id: String.t() | nil,
30 32
          owner_user_id: Ecto.UUID.t() | nil,
31 33
          owner_visitor_id: Ecto.UUID.t() | nil,
32 34
          workspace: map() | nil,
lib/openagents/tools/publish_changes.ex added +86

@@ -0,0 +1,86 @@

1
defmodule OpenAgents.Tools.PublishChanges do
2
  @moduledoc "Publishes all chat workspace changes to an assigned Forge branch."
3
4
  @behaviour OpenAgents.Tools.Tool
5
6
  alias OpenAgents.Modules.Metadata
7
  alias OpenAgents.Tools.{ExecutionResult, Tool, WorkspacePublication}
8
9
  @impl true
10
  def specification do
11
    %Tool{
12
      module_id: "openagents.tool.publish_changes.v1",
13
      name: "publish_changes",
14
      version: 1,
15
      description:
16
        "Commit all current workspace changes and publish them to the chat run's assigned " <>
17
          "OpenAgents Forge branch. The server chooses the repository, remote, and branch.",
18
      input_schema: %{
19
        "type" => "object",
20
        "properties" => %{
21
          "message" => %{"type" => "string", "minLength" => 1, "maxLength" => 2_000},
22
          "expected_workspace_digest" => %{
23
            "type" => "string",
24
            "pattern" => "^[0-9a-f]{64}$"
25
          }
26
        },
27
        "required" => ["message"],
28
        "additionalProperties" => false
29
      },
30
      output_schema: %{"type" => "object", "properties" => %{}, "additionalProperties" => true},
31
      side_effect: :external_effect,
32
      required_scope: "browser_conversation",
33
      required_authority: "repository.write",
34
      executor: %{
35
        id: "openagents.forge.workspace_publication",
36
        disclosure: "the OpenAgents runtime, publishing an isolated chat branch to its own Forge"
37
      },
38
      maintainer: "OpenAgents",
39
      attribution: ["OpenAgentsInc/openagents.com"],
40
      policy_facets: %{
41
        "privacy" => "browser_conversation",
42
        "residency" => "host",
43
        "consent" => "explicit_publication"
44
      },
45
      module_metadata:
46
        Metadata.first_party("repository.write", "browser_conversation",
47
          effect: :external_effect,
48
          privacy: "browser_conversation",
49
          residency: "host",
50
          surfaces: ["text"],
51
          approval_class: "explicit_operator_approval",
52
          approval_enforcement: "host_receipt"
53
        ),
54
      timeout_ms: 60_000,
55
      maximum_input_bytes: 8_192,
56
      maximum_output_bytes: 32_768,
57
      implementation: __MODULE__
58
    }
59
  end
60
61
  @impl true
62
  def execute(%{"message" => message} = arguments, context) do
63
    case WorkspacePublication.publish(
64
           context,
65
           String.trim(message),
66
           Map.get(arguments, "expected_workspace_digest")
67
         ) do
68
      {:ok, result} ->
69
        receipt = result["receipt"]
70
71
        {:ok,
72
         %ExecutionResult{
73
           result: result,
74
           target_receipt_refs: [
75
             "repository-publication:#{result["publication_id"]}",
76
             "forge-commit:#{result["repository"]}:#{result["published_oid"]}",
77
             "forge-branch:#{result["repository"]}:#{result["branch"]}",
78
             "forge-push:#{result["repository"]}:#{receipt["wal_seq"]}"
79
           ]
80
         }}
81
82
      {:error, reason} ->
83
        {:error, reason}
84
    end
85
  end
86
end
lib/openagents/tools/runner.ex modified +2

@@ -60,6 +60,8 @@ defmodule OpenAgents.Tools.Runner do

60 60
  end
61 61
62 62
  defp execute_admitted(tool, artifact, call_id, arguments, context, options, started_at) do
63
    context = %{context | current_tool_call_id: call_id}
64
63 65
    task =
64 66
      Task.Supervisor.async_nolink(OpenAgents.ToolTaskSupervisor, fn ->
65 67
        tool.implementation.execute(arguments, context)
lib/openagents/tools/workspace_publication.ex added +438

@@ -0,0 +1,438 @@

1
defmodule OpenAgents.Tools.WorkspacePublication do
2
  @moduledoc "Publishes an authenticated chat workspace to its assigned Forge branch."
3
4
  import Ecto.Query
5
6
  alias OpenAgents.{Accounts, Forge, Repositories}
7
  alias OpenAgents.Forge.Pushes
8
  alias OpenAgents.Repositories.{Repository, RepositoryPublication}
9
  alias OpenAgents.Tools.{ExecutionContext, WorkspaceFiles}
10
11
  @branch_prefix "openagents/chat/"
12
  @identity_name "OpenAgents chat agent"
13
  @identity_email "chat-agent@openagents.com"
14
15
  def publish(%ExecutionContext{} = context, message, expected_digest)
16
      when is_binary(message) do
17
    message = String.trim(message)
18
19
    with true <- message != "" || {:error, :invalid_publish_message},
20
         {:ok, binding} <- publication_binding(context),
21
         {:ok, publication} <-
22
           find_or_create_publication(context, binding, message, expected_digest) do
23
      :global.trans({{__MODULE__, publication.idempotency_key}, self()}, fn ->
24
        publish_once(publication.id, binding, message, expected_digest)
25
      end)
26
    end
27
  end
28
29
  def publish(_context, _message, _expected_digest), do: {:error, :invalid_publish_message}
30
31
  def branch(%ExecutionContext{} = context) do
32
    opaque =
33
      [context.owner_user_id, context.conversation_id, workspace_ref(context)]
34
      |> Enum.map_join(":", &to_string/1)
35
      |> then(&:crypto.hash(:sha256, &1))
36
      |> Base.encode16(case: :lower)
37
      |> binary_part(0, 24)
38
39
    @branch_prefix <> opaque
40
  end
41
42
  def workspace_digest(%ExecutionContext{} = context) do
43
    with {:ok, binding} <- publication_binding(context),
44
         {:ok, source_oid} <- git(binding.root, ["rev-parse", "HEAD"]),
45
         {:ok, tree_oid} <- staged_tree(binding.root) do
46
      {:ok, workspace_digest(source_oid, tree_oid)}
47
    end
48
  end
49
50
  def approval_receipt(scope_ref, receipt_ref)
51
      when is_binary(scope_ref) and is_binary(receipt_ref) do
52
    %{
53
      "schema" => "sarah.module_approval.v1",
54
      "approval_class" => "explicit_operator_approval",
55
      "module_id" => "openagents.tool.publish_changes.v1",
56
      "version" => 1,
57
      "scope_ref" => scope_ref,
58
      "explicit" => true,
59
      "actor_type" => "person",
60
      "receipt_ref" => receipt_ref
61
    }
62
  end
63
64
  defp publication_binding(
65
         %ExecutionContext{workspace: workspace, owner_user_id: user_id} = context
66
       )
67
       when is_map(workspace) and is_binary(user_id) do
68
    repository_id = fetch(workspace, "repository_id", :repository_id)
69
70
    with {:ok, target} <- WorkspaceFiles.resolve(context, ".git", :write),
71
         %Repository{} = repository <- OpenAgents.Repo.get(Repository, repository_id),
72
         user when not is_nil(user) <- Accounts.get_user(user_id),
73
         true <- Repositories.writable?(repository, user),
74
         true <- repository.lifecycle_state == "ready",
75
         repository <- OpenAgents.Repo.preload(repository, :namespace),
76
         push_url when is_binary(push_url) and push_url != "" <- push_url(repository),
77
         assigned_branch = branch(context),
78
         false <- assigned_branch == repository.default_branch do
79
      {:ok,
80
       %{
81
         root: target.root,
82
         repository: repository,
83
         branch: assigned_branch,
84
         push_url: push_url
85
       }}
86
    else
87
      nil -> {:error, :repository_not_found}
88
      false -> {:error, :repository_write_refused}
89
      {:error, reason} -> {:error, reason}
90
      _ -> {:error, :forge_push_unconfigured}
91
    end
92
  end
93
94
  defp publication_binding(_context), do: {:error, :repository_workspace_unavailable}
95
96
  defp push_url(repository) do
97
    case Application.get_env(:openagents, :workspace_publish_url_resolver) do
98
      resolver when is_function(resolver, 1) -> resolver.(repository)
99
      _ -> OpenAgents.Tools.Repository.push_url()
100
    end
101
  end
102
103
  defp staged_tree(root) do
104
    index = Path.join(System.tmp_dir!(), "openagents-publish-#{Ecto.UUID.generate()}.index")
105
106
    try do
107
      with {:ok, _} <- git(root, ["read-tree", "HEAD"], index),
108
           {:ok, _} <- git(root, ["add", "-A"], index),
109
           {:ok, tree} <- git(root, ["write-tree"], index) do
110
        {:ok, tree}
111
      end
112
    after
113
      File.rm(index)
114
    end
115
  end
116
117
  defp commit_tree(root, tree_oid, source_oid, message) do
118
    with {:ok, date} <- git(root, ["show", "-s", "--format=%aI", source_oid]),
119
         {:ok, commit_oid} <-
120
           git(root, ["commit-tree", tree_oid, "-p", source_oid, "-m", message], nil,
121
             GIT_AUTHOR_NAME: @identity_name,
122
             GIT_AUTHOR_EMAIL: @identity_email,
123
             GIT_COMMITTER_NAME: @identity_name,
124
             GIT_COMMITTER_EMAIL: @identity_email,
125
             GIT_AUTHOR_DATE: date,
126
             GIT_COMMITTER_DATE: date
127
           ) do
128
      {:ok, commit_oid}
129
    end
130
  end
131
132
  defp publish_ref(binding, commit_oid, expected_previous_oid) do
133
    with {:ok, remote_oid} <- remote_oid(binding.push_url, binding.branch) do
134
      publish_ref_from_remote(binding, commit_oid, expected_previous_oid, remote_oid)
135
    end
136
  end
137
138
  defp publish_ref_from_remote(_binding, commit_oid, _expected, commit_oid),
139
    do: {:ok, "reconciled"}
140
141
  defp publish_ref_from_remote(_binding, _commit_oid, expected, remote_oid)
142
       when expected != remote_oid,
143
       do: {:error, :publish_lease_failed}
144
145
  defp publish_ref_from_remote(binding, commit_oid, expected_previous_oid, _remote_oid) do
146
    lease =
147
      "--force-with-lease=refs/heads/#{binding.branch}:#{expected_previous_oid || String.duplicate("0", 40)}"
148
149
    case git(binding.root, [
150
           "-c",
151
           "credential.helper=",
152
           "push",
153
           lease,
154
           binding.push_url,
155
           "#{commit_oid}:refs/heads/#{binding.branch}"
156
         ]) do
157
      {:ok, _output} ->
158
        {:ok, "published"}
159
160
      {:error, _reason} ->
161
        case remote_oid(binding.push_url, binding.branch) do
162
          {:ok, ^commit_oid} -> {:ok, "reconciled"}
163
          {:ok, _other} -> {:error, :publish_lease_failed}
164
          {:error, _reason} -> {:error, :publish_result_uncertain}
165
        end
166
    end
167
  end
168
169
  defp remote_oid(url, branch) do
170
    case System.cmd("git", ["ls-remote", url, "refs/heads/#{branch}"],
171
           stderr_to_stdout: true,
172
           env: [{"GIT_TERMINAL_PROMPT", "0"}]
173
         ) do
174
      {"", 0} -> {:ok, nil}
175
      {output, 0} -> {:ok, output |> String.split() |> List.first()}
176
      {_output, _status} -> {:error, :forge_remote_unavailable}
177
    end
178
  end
179
180
  defp receipt(binding, commit_oid) do
181
    resolver =
182
      Application.get_env(:openagents, :workspace_publish_receipt_resolver, &forge_receipt/3)
183
184
    case resolver.(binding.repository, binding.branch, commit_oid) do
185
      {:ok, receipt} when is_map(receipt) -> {:ok, receipt}
186
      {:error, reason} -> {:error, reason}
187
      _ -> {:error, :forge_wal_receipt_missing}
188
    end
189
  end
190
191
  defp forge_receipt(repository, branch, commit_oid) do
192
    _inserted = Pushes.reconcile_receipts(repository.storage_key)
193
    ref = "refs/heads/#{branch}"
194
195
    repository.storage_key
196
    |> Forge.recent_pushes(50)
197
    |> Enum.find(fn receipt -> get_in(receipt.refs, [ref, "new"]) == commit_oid end)
198
    |> case do
199
      nil ->
200
        {:error, :forge_wal_receipt_missing}
201
202
      receipt ->
203
        {:ok,
204
         %{
205
           "schema" => "openagents.forge_push_receipt.v1",
206
           "id" => receipt.id,
207
           "wal_seq" => receipt.wal_seq,
208
           "ref" => ref
209
         }}
210
    end
211
  end
212
213
  defp check_digest(nil, _actual), do: :ok
214
  defp check_digest(actual, actual), do: :ok
215
  defp check_digest(_expected, _actual), do: {:error, :stale_workspace_digest}
216
217
  defp changed(tree, tree), do: {:error, :nothing_to_publish}
218
  defp changed(_source_tree, _tree), do: :ok
219
220
  defp find_or_create_publication(context, binding, message, expected_digest) do
221
    argument_digest = digest(Jason.encode!(%{message: message, expected_digest: expected_digest}))
222
    idempotency_key = idempotency_key(context, argument_digest)
223
224
    attrs = %{
225
      repository_id: binding.repository.id,
226
      owner_user_id: context.owner_user_id,
227
      conversation_id: context.conversation_id,
228
      tool_call_id: context.current_tool_call_id,
229
      workspace_ref: workspace_ref(context),
230
      idempotency_key: idempotency_key,
231
      argument_digest: argument_digest,
232
      message: message,
233
      expected_workspace_digest: expected_digest,
234
      branch: binding.branch,
235
      expected_previous_oid: latest_published_oid(binding.repository.id, binding.branch)
236
    }
237
238
    case OpenAgents.Repo.get_by(RepositoryPublication, idempotency_key: idempotency_key) do
239
      %RepositoryPublication{argument_digest: ^argument_digest} = publication ->
240
        {:ok, publication}
241
242
      %RepositoryPublication{} ->
243
        {:error, :publication_idempotency_conflict}
244
245
      nil ->
246
        %RepositoryPublication{}
247
        |> RepositoryPublication.changeset(attrs)
248
        |> OpenAgents.Repo.insert()
249
        |> case do
250
          {:ok, publication} -> {:ok, publication}
251
          {:error, _changeset} -> refetch_publication(idempotency_key, argument_digest)
252
        end
253
    end
254
  end
255
256
  defp refetch_publication(idempotency_key, argument_digest) do
257
    case OpenAgents.Repo.get_by(RepositoryPublication, idempotency_key: idempotency_key) do
258
      %RepositoryPublication{argument_digest: ^argument_digest} = publication ->
259
        {:ok, publication}
260
261
      %RepositoryPublication{} ->
262
        {:error, :publication_idempotency_conflict}
263
264
      nil ->
265
        {:error, :publication_receipt_unavailable}
266
    end
267
  end
268
269
  defp publish_once(publication_id, binding, message, expected_digest) do
270
    publication = OpenAgents.Repo.get!(RepositoryPublication, publication_id)
271
272
    if publication.state == "accepted" and is_map(publication.result) do
273
      {:ok, publication.result}
274
    else
275
      result =
276
        with {:ok, source_oid} <- git(binding.root, ["rev-parse", "HEAD"]),
277
             {:ok, tree_oid} <- staged_tree(binding.root),
278
             {:ok, source_tree} <- git(binding.root, ["rev-parse", "HEAD^{tree}"]),
279
             observed_digest = workspace_digest(source_oid, tree_oid),
280
             :ok <-
281
               update_publication(publication, %{
282
                 state: "committing",
283
                 source_oid: source_oid,
284
                 observed_workspace_digest: observed_digest
285
               }),
286
             :ok <- check_digest(expected_digest, observed_digest),
287
             :ok <- changed(source_tree, tree_oid),
288
             {:ok, commit_oid} <- commit_tree(binding.root, tree_oid, source_oid, message),
289
             :ok <-
290
               update_publication(publication, %{state: "pushing", published_oid: commit_oid}),
291
             {:ok, disposition} <-
292
               publish_ref(binding, commit_oid, publication.expected_previous_oid),
293
             {:ok, receipt} <- receipt(binding, commit_oid) do
294
          publication_result = %{
295
            "schema" => "openagents.workspace_publication.v1",
296
            "publication_id" => publication.id,
297
            "repository" => "#{binding.repository.namespace.slug}/#{binding.repository.name}",
298
            "branch" => binding.branch,
299
            "base_branch" => binding.repository.default_branch,
300
            "source_oid" => source_oid,
301
            "published_oid" => commit_oid,
302
            "workspace_digest" => observed_digest,
303
            "disposition" => disposition,
304
            "compare_url" => compare_url(binding.repository, binding.branch),
305
            "summary" => diff_summary(binding.root, source_oid, commit_oid),
306
            "receipt" => receipt
307
          }
308
309
          :ok =
310
            update_publication(publication, %{
311
              state: "accepted",
312
              published_oid: commit_oid,
313
              wal_seq: receipt["wal_seq"],
314
              result: publication_result,
315
              error_code: nil
316
            })
317
318
          {:ok, publication_result}
319
        end
320
321
      record_publication_result(publication, result)
322
    end
323
  end
324
325
  defp record_publication_result(_publication, {:ok, _result} = success), do: success
326
327
  defp record_publication_result(publication, {:error, reason} = error) do
328
    state = if(reason == :publish_result_uncertain, do: "uncertain", else: failure_state(reason))
329
    :ok = update_publication(publication, %{state: state, error_code: error_code(reason)})
330
    error
331
  end
332
333
  defp failure_state(:nothing_to_publish), do: "nothing_to_publish"
334
  defp failure_state(_reason), do: "failed"
335
336
  defp error_code(reason) when is_atom(reason), do: Atom.to_string(reason)
337
  defp error_code({reason, _detail}) when is_atom(reason), do: Atom.to_string(reason)
338
  defp error_code(_reason), do: "publication_failed"
339
340
  defp update_publication(publication, attrs) do
341
    publication
342
    |> RepositoryPublication.changeset(attrs)
343
    |> OpenAgents.Repo.update()
344
    |> case do
345
      {:ok, _publication} -> :ok
346
      {:error, _changeset} -> {:error, :publication_receipt_unavailable}
347
    end
348
  end
349
350
  defp latest_published_oid(repository_id, branch) do
351
    from(publication in RepositoryPublication,
352
      where:
353
        publication.repository_id == ^repository_id and publication.branch == ^branch and
354
          publication.state == "accepted",
355
      order_by: [desc: publication.inserted_at],
356
      limit: 1,
357
      select: publication.published_oid
358
    )
359
    |> OpenAgents.Repo.one()
360
  end
361
362
  defp idempotency_key(%ExecutionContext{current_tool_call_id: call_id} = context, _digest)
363
       when is_binary(call_id) and call_id != "" do
364
    digest(Enum.join([context.owner_user_id, context.conversation_id, call_id], ":"))
365
  end
366
367
  defp idempotency_key(context, argument_digest) do
368
    digest(
369
      Enum.join(
370
        [context.owner_user_id, context.conversation_id, workspace_ref(context), argument_digest],
371
        ":"
372
      )
373
    )
374
  end
375
376
  defp digest(value),
377
    do: :crypto.hash(:sha256, value) |> Base.encode16(case: :lower)
378
379
  defp workspace_digest(source_oid, tree_oid),
380
    do: :crypto.hash(:sha256, source_oid <> ":" <> tree_oid) |> Base.encode16(case: :lower)
381
382
  defp diff_summary(root, source_oid, commit_oid) do
383
    case git(root, ["diff-tree", "--no-commit-id", "--numstat", "-r", source_oid, commit_oid]) do
384
      {:ok, output} ->
385
        files = String.split(output, "\n", trim: true)
386
387
        %{insertions: insertions, deletions: deletions} =
388
          Enum.reduce(files, %{insertions: 0, deletions: 0}, fn line, acc ->
389
            case String.split(line, "\t", parts: 3) do
390
              [added, removed, _path] ->
391
                %{
392
                  insertions: acc.insertions + number(added),
393
                  deletions: acc.deletions + number(removed)
394
                }
395
396
              _ ->
397
                acc
398
            end
399
          end)
400
401
        %{
402
          "files_changed" => length(files),
403
          "insertions" => insertions,
404
          "deletions" => deletions
405
        }
406
407
      {:error, _reason} ->
408
        %{"files_changed" => 0, "insertions" => 0, "deletions" => 0}
409
    end
410
  end
411
412
  defp number("-"), do: 0
413
  defp number(value), do: String.to_integer(value)
414
415
  defp compare_url(repository, branch) do
416
    OpenAgentsWeb.Endpoint.url() <>
417
      "/#{repository.namespace.slug}/#{repository.name}/compare/#{repository.default_branch}...#{branch}"
418
  end
419
420
  defp git(root, args, index \\ nil, extra_env \\ []) do
421
    env =
422
      [{"GIT_TERMINAL_PROMPT", "0"}] ++
423
        if(index, do: [{"GIT_INDEX_FILE", index}], else: []) ++
424
        Enum.map(extra_env, fn {key, value} -> {Atom.to_string(key), value} end)
425
426
    case System.cmd("git", ["-C", root | args], stderr_to_stdout: true, env: env) do
427
      {output, 0} -> {:ok, String.trim(output)}
428
      {output, _status} -> {:error, {:git_failed, String.slice(output, 0, 500)}}
429
    end
430
  end
431
432
  defp workspace_ref(%ExecutionContext{workspace: workspace}) when is_map(workspace),
433
    do: fetch(workspace, "workspace_ref", :workspace_ref) || "workspace:unknown"
434
435
  defp workspace_ref(_context), do: "workspace:missing"
436
437
  defp fetch(map, string_key, atom_key), do: Map.get(map, string_key, Map.get(map, atom_key))
438
end
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -231,7 +231,8 @@

231 231
    20260822132511,
232 232
    20260822153929,
233 233
    20260822234211,
234
    20260823000143
234
    20260823000143,
235
    20260823010819
235 236
  ],
236 237
  "required_tables": [
237 238
    "users",
priv/repo/migrations/20260823010819_create_repository_publications.exs added +42

@@ -0,0 +1,42 @@

1
defmodule OpenAgents.Repo.Migrations.CreateRepositoryPublications do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:repository_publications, primary_key: false) do
6
      add :id, :binary_id, primary_key: true
7
8
      add :repository_id,
9
          references(:repositories, type: :binary_id, on_delete: :delete_all),
10
          null: false
11
12
      add :owner_user_id, references(:users, type: :binary_id, on_delete: :restrict), null: false
13
      add :conversation_id, :binary_id
14
      add :tool_call_id, :string
15
      add :workspace_ref, :string, null: false
16
      add :idempotency_key, :string, null: false
17
      add :argument_digest, :string, null: false
18
      add :message, :text, null: false
19
      add :expected_workspace_digest, :string
20
      add :observed_workspace_digest, :string
21
      add :branch, :string, null: false
22
      add :source_oid, :string
23
      add :expected_previous_oid, :string
24
      add :published_oid, :string
25
      add :state, :string, null: false, default: "requested"
26
      add :wal_seq, :bigint
27
      add :result, :map
28
      add :error_code, :string
29
30
      timestamps(type: :utc_datetime_usec)
31
    end
32
33
    create unique_index(:repository_publications, [:idempotency_key])
34
    create index(:repository_publications, [:repository_id, :workspace_ref, :branch])
35
36
    create constraint(:repository_publications, :repository_publications_state_check,
37
             check:
38
               "state IN ('requested', 'committing', 'pushing', 'accepted', 'uncertain', " <>
39
                 "'failed', 'nothing_to_publish')"
40
           )
41
  end
42
end
test/openagents/tools/publish_changes_test.exs added +220

@@ -0,0 +1,220 @@

1
defmodule OpenAgents.Tools.PublishChangesTest do
2
  use OpenAgents.DataCase, async: false
3
4
  alias OpenAgents.Tools.{
5
    ExecutionContext,
6
    PublishChanges,
7
    Registry,
8
    Runner,
9
    WorkspacePublication
10
  }
11
12
  setup do
13
    base = Path.join(System.tmp_dir!(), "publish-changes-#{System.unique_integer([:positive])}")
14
    root = Path.join(base, "workspace")
15
    remote = Path.join(base, "remote.git")
16
    File.mkdir_p!(root)
17
18
    git!(root, ["init", "-b", "main"])
19
    File.write!(Path.join(root, "README.md"), "initial\n")
20
    git!(root, ["add", "README.md"])
21
22
    git!(root, [
23
      "-c",
24
      "user.name=Fixture",
25
      "-c",
26
      "user.email=fixture@example.com",
27
      "commit",
28
      "-m",
29
      "Initial"
30
    ])
31
32
    System.cmd("git", ["clone", "--bare", root, remote]) |> successful!()
33
34
    user = repository_user_fixture("publisher")
35
    repository = repository_with_member_fixture(user, %{owner: "OpenAgentsInc"})
36
37
    previous_url = Application.get_env(:openagents, :workspace_publish_url_resolver)
38
    previous_receipt = Application.get_env(:openagents, :workspace_publish_receipt_resolver)
39
    Application.put_env(:openagents, :workspace_publish_url_resolver, fn _repo -> remote end)
40
41
    Application.put_env(:openagents, :workspace_publish_receipt_resolver, fn _repo, branch, oid ->
42
      {:ok,
43
       %{
44
         "schema" => "openagents.forge_push_receipt.v1",
45
         "id" => "receipt-test",
46
         "wal_seq" => 17,
47
         "ref" => "refs/heads/#{branch}",
48
         "oid" => oid
49
       }}
50
    end)
51
52
    on_exit(fn ->
53
      restore(:workspace_publish_url_resolver, previous_url)
54
      restore(:workspace_publish_receipt_resolver, previous_receipt)
55
      File.rm_rf(base)
56
    end)
57
58
    context = %ExecutionContext{
59
      scope: "browser_conversation",
60
      scope_ref: "conversation:publish",
61
      authorities: MapSet.new(["repository.write"]),
62
      owner_user_id: user.id,
63
      conversation_id: Ecto.UUID.generate(),
64
      workspace: %{
65
        "type" => "repository_workspace",
66
        "root" => root,
67
        "canonical" => false,
68
        "read_only" => false,
69
        "workspace_ref" => "workspace:publish",
70
        "repository_id" => repository.id
71
      }
72
    }
73
74
    %{context: context, root: root, remote: remote, repository: repository}
75
  end
76
77
  test "publishes all changes to only the server-assigned branch and returns receipts", %{
78
    context: context,
79
    root: root,
80
    remote: remote,
81
    repository: repository
82
  } do
83
    File.write!(Path.join(root, "README.md"), "changed\n")
84
    File.write!(Path.join(root, "new.txt"), "new\n")
85
    assert {:ok, digest} = WorkspacePublication.workspace_digest(context)
86
87
    assert {:ok, execution} =
88
             PublishChanges.execute(
89
               %{"message" => "Publish workspace", "expected_workspace_digest" => digest},
90
               context
91
             )
92
93
    result = execution.result
94
    branch = WorkspacePublication.branch(context)
95
    assert result["branch"] == branch
96
    assert result["repository"] == "#{repository.namespace.slug}/#{repository.name}"
97
    assert result["summary"] == %{"files_changed" => 2, "insertions" => 2, "deletions" => 1}
98
    assert result["receipt"]["wal_seq"] == 17
99
    assert Enum.any?(execution.target_receipt_refs, &String.starts_with?(&1, "forge-push:"))
100
101
    published = ls_remote!(remote, branch)
102
    assert published == result["published_oid"]
103
    assert ls_remote!(remote, "main") == result["source_oid"]
104
    assert git!(root, ["status", "--porcelain"]) =~ "README.md"
105
  end
106
107
  test "retries are deterministic and do not create a second commit", %{
108
    context: context,
109
    root: root
110
  } do
111
    File.write!(Path.join(root, "README.md"), "changed\n")
112
    assert {:ok, first} = WorkspacePublication.publish(context, "Retry-safe publication", nil)
113
    assert {:ok, second} = WorkspacePublication.publish(context, "Retry-safe publication", nil)
114
    assert first == second
115
  end
116
117
  test "refuses stale, empty, read-only, unbound, and unauthorized workspaces", %{
118
    context: context,
119
    root: root
120
  } do
121
    assert {:error, :nothing_to_publish} =
122
             WorkspacePublication.publish(context, "No changes", nil)
123
124
    File.write!(Path.join(root, "README.md"), "changed\n")
125
126
    assert {:error, :stale_workspace_digest} =
127
             WorkspacePublication.publish(context, "Stale", String.duplicate("0", 64))
128
129
    assert {:error, :workspace_read_only} =
130
             WorkspacePublication.publish(
131
               put_in(context.workspace["read_only"], true),
132
               "Read only",
133
               nil
134
             )
135
136
    assert {:error, :repository_workspace_unavailable} =
137
             WorkspacePublication.publish(%{context | workspace: nil}, "Unbound", nil)
138
139
    stranger = repository_user_fixture("stranger")
140
141
    assert {:error, :repository_write_refused} =
142
             WorkspacePublication.publish(
143
               %{context | owner_user_id: stranger.id},
144
               "No access",
145
               nil
146
             )
147
  end
148
149
  test "runner requires authority and an exact publication approval", %{
150
    context: context,
151
    root: root
152
  } do
153
    File.write!(Path.join(root, "README.md"), "approved\n")
154
    assert {:ok, snapshot} = Registry.build([PublishChanges])
155
156
    call = %{
157
      call_id: "call-publish",
158
      name: "publish_changes",
159
      version: 1,
160
      raw_arguments: Jason.encode!(%{"message" => "Approved publication"})
161
    }
162
163
    assert {:ok, no_approval} = Runner.run(snapshot, call, context)
164
    assert no_approval["error"]["code"] == "module_approval_required"
165
166
    receipt = WorkspacePublication.approval_receipt(context.scope_ref, "approval:publish")
167
168
    assert {:ok, no_authority} =
169
             Runner.run(snapshot, call, %{
170
               context
171
               | authorities: MapSet.new(),
172
                 approval_receipts: [receipt]
173
             })
174
175
    assert no_authority["error"]["code"] == "authority_refused"
176
177
    assert {:ok, published} =
178
             Runner.run(snapshot, call, %{context | approval_receipts: [receipt]})
179
180
    assert published["status"] == "succeeded"
181
    assert published["result"]["branch"] == WorkspacePublication.branch(context)
182
  end
183
184
  test "the model cannot supply a repository, remote, or branch", %{context: context, root: root} do
185
    File.write!(Path.join(root, "README.md"), "changed\n")
186
    assert {:ok, snapshot} = Registry.build([PublishChanges])
187
    receipt = WorkspacePublication.approval_receipt(context.scope_ref, "approval:publish")
188
189
    for forbidden <- ["repository", "remote", "branch"] do
190
      call = %{
191
        call_id: "call-#{forbidden}",
192
        name: "publish_changes",
193
        version: 1,
194
        raw_arguments: Jason.encode!(%{"message" => "Attempt", forbidden => "attacker/value"})
195
      }
196
197
      assert {:ok, refused} =
198
               Runner.run(snapshot, call, %{context | approval_receipts: [receipt]})
199
200
      assert refused["error"]["code"] == "additional_property_not_allowed"
201
    end
202
  end
203
204
  defp git!(root, args) do
205
    System.cmd("git", ["-C", root | args], stderr_to_stdout: true) |> successful!()
206
  end
207
208
  defp successful!({output, 0}), do: String.trim(output)
209
210
  defp ls_remote!(remote, branch) do
211
    remote
212
    |> then(&System.cmd("git", ["ls-remote", &1, "refs/heads/#{branch}"]))
213
    |> successful!()
214
    |> String.split()
215
    |> List.first()
216
  end
217
218
  defp restore(key, nil), do: Application.delete_env(:openagents, key)
219
  defp restore(key, value), do: Application.put_env(:openagents, key, value)
220
end

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