Run checks stack-aware with synthetic refs and per-layer policies

c17b6e8b6141 · Devin AI · · parent efc10d0835e2

Run checks stack-aware with synthetic refs and per-layer policies

Check validity keys to the full context — pull request, head OID,
effective base OID, and workflow definition OID — never the head OID
alone, so a trunk advance invalidates affected runs instead of reusing
a verdict for a state that no longer exists. A layer that no longer
sits on the trunk chain tests an immutable synthetic snapshot (a
boundary-scoped replay of every layer through its position onto the
current trunk) published once, from absent, under
refs/internal/checks/<run-id>; nothing ever moves that ref.

Workflow definitions live in .forge/workflows.json in the
effective-base tree, and each workflow declares an explicit run_on
policy: every_layer, top_layer_only, bottom_layer_only, changed_paths,
or merge_group_only. A plan skips a layer only through that declared
policy and returns every skip explicitly, and the merge_group context
runs every workflow so a required check never silently skips.

Closes #53

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>
Closes
#53

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

  • added lib/openagents/stacks/check_run.ex
  • added lib/openagents/stacks/checks.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823122038_create_stack_check_runs.exs
  • added test/openagents/stacks/checks_test.exs

Diff

5 files changed, +1134 -1

lib/openagents/stacks/check_run.ex added +126

@@ -0,0 +1,126 @@

1
defmodule OpenAgents.Stacks.CheckRun do
2
  @moduledoc """
3
  One check evaluation of one stacked pull request layer.
4
5
  Validity keys to the full context — `(pull_request_id, workflow_name,
6
  context, head_oid, effective_base_oid, workflow_definition_oid)` — never
7
  to the head OID alone (docs/stacked-prs.md section 11.3). `tested_oid` is
8
  the immutable snapshot the check actually runs against: the layer head
9
  when the stack is rebased onto the current trunk, otherwise a synthetic
10
  commit published under `refs/internal/checks/<run-id>` whose tree is the
11
  current trunk plus every layer through this position (section 11.4).
12
13
  A trunk advance changes the effective base OID, so existing runs no
14
  longer match the current identity and become `stale` rather than
15
  silently vouching for a state that no longer exists.
16
  """
17
  use Ecto.Schema
18
  import Ecto.Changeset
19
20
  @primary_key {:id, :binary_id, autogenerate: true}
21
  @foreign_key_type :binary_id
22
  @timestamps_opts [type: :utc_datetime_usec]
23
24
  @states ~w(pending passed failed stale)
25
  @contexts ~w(layer merge_group)
26
  @run_on_policies ~w(every_layer top_layer_only bottom_layer_only changed_paths merge_group_only)
27
28
  schema "stack_check_runs" do
29
    belongs_to :repository, OpenAgents.Repositories.Repository
30
    belongs_to :stack, OpenAgents.Stacks.Stack
31
    belongs_to :pull_request, OpenAgents.PullRequests.PullRequest
32
33
    field :workflow_name, :string
34
    field :run_on, :string
35
    field :required, :boolean, default: false
36
    field :run_reason, :string
37
    field :context, :string, default: "layer"
38
39
    field :head_oid, OpenAgents.Stacks.OID
40
    field :effective_base_oid, OpenAgents.Stacks.OID
41
    field :workflow_definition_oid, OpenAgents.Stacks.OID
42
    field :tested_oid, OpenAgents.Stacks.OID
43
    field :synthetic_ref, :string
44
45
    field :state, :string, default: "pending"
46
    field :concluded_at, :utc_datetime_usec
47
48
    timestamps()
49
  end
50
51
  def states, do: @states
52
  def contexts, do: @contexts
53
  def run_on_policies, do: @run_on_policies
54
55
  def changeset(check_run, attrs) do
56
    check_run
57
    |> cast(attrs, [
58
      :workflow_name,
59
      :run_on,
60
      :required,
61
      :run_reason,
62
      :context,
63
      :head_oid,
64
      :effective_base_oid,
65
      :workflow_definition_oid,
66
      :tested_oid,
67
      :synthetic_ref,
68
      :state
69
    ])
70
    |> put_programmatic_change(attrs, :id)
71
    |> put_programmatic_change(attrs, :repository_id)
72
    |> put_programmatic_change(attrs, :stack_id)
73
    |> put_programmatic_change(attrs, :pull_request_id)
74
    |> validate_required([
75
      :repository_id,
76
      :stack_id,
77
      :pull_request_id,
78
      :workflow_name,
79
      :run_on,
80
      :run_reason,
81
      :context,
82
      :head_oid,
83
      :effective_base_oid,
84
      :workflow_definition_oid,
85
      :tested_oid,
86
      :state
87
    ])
88
    |> validate_inclusion(:state, @states)
89
    |> validate_inclusion(:context, @contexts)
90
    |> validate_inclusion(:run_on, @run_on_policies)
91
    |> unique_constraint(
92
      [
93
        :pull_request_id,
94
        :workflow_name,
95
        :context,
96
        :head_oid,
97
        :effective_base_oid,
98
        :workflow_definition_oid
99
      ],
100
      name: :stack_check_runs_identity_index
101
    )
102
    |> check_constraint(:state, name: :stack_check_runs_state_check)
103
    |> check_constraint(:context, name: :stack_check_runs_context_check)
104
    |> check_constraint(:run_on, name: :stack_check_runs_run_on_check)
105
    |> foreign_key_constraint(:repository_id)
106
    |> foreign_key_constraint(:stack_id)
107
    |> foreign_key_constraint(:pull_request_id)
108
  end
109
110
  @doc "Concludes a pending run as passed or failed."
111
  def conclusion_changeset(check_run, state, concluded_at) when state in ~w(passed failed) do
112
    change(check_run, state: state, concluded_at: concluded_at)
113
  end
114
115
  @doc "Marks a run stale: its effective base no longer matches the trunk."
116
  def stale_changeset(check_run) do
117
    change(check_run, state: "stale")
118
  end
119
120
  defp put_programmatic_change(changeset, attrs, field) do
121
    case Map.fetch(attrs, field) do
122
      {:ok, value} -> put_change(changeset, field, value)
123
      :error -> changeset
124
    end
125
  end
126
end
lib/openagents/stacks/checks.ex added +446

@@ -0,0 +1,446 @@

1
defmodule OpenAgents.Stacks.Checks do
2
  @moduledoc """
3
  Stack-aware check planning (docs/stacked-prs.md sections 11.3–11.5).
4
5
  Checks on a stacked pull request test an immutable snapshot that includes
6
  every lower layer. Each planned run keys its validity to the full context
7
  — `(pull_request_id, workflow_name, context, head_oid, effective_base_oid,
8
  workflow_definition_oid)` — so a trunk advance, a head move, or a workflow
9
  definition change produces a different identity instead of reusing a
10
  verdict for a state that no longer exists. Planning marks runs whose
11
  effective base no longer matches the trunk tip `stale` before creating
12
  runs for the current identity.
13
14
  When a layer head does not already sit on the snapshot base (the stack
15
  needs a rebase, or the plan speculates for a merge group), the snapshot
16
  is a boundary-scoped replay of the layer chain onto the current trunk,
17
  published once under `refs/internal/checks/<run-id>` with an
18
  expected-absent compare-and-swap. Nothing ever moves that ref afterward:
19
  the run's snapshot is immutable for its lifetime.
20
21
  Workflow definitions live in `#{inspect(".forge/workflows.json")}` in the
22
  effective-base tree — never an intermediate branch tree — and each
23
  workflow declares an explicit `run_on` policy: `every_layer`,
24
  `top_layer_only`, `bottom_layer_only`, `changed_paths` (with declared
25
  path prefixes), or `merge_group_only`. A plan skips a layer only through
26
  that declared policy, and every skip is returned explicitly in the plan
27
  rather than silently dropped. In the `merge_group` context every workflow
28
  runs: the merge group is the authoritative gate and never optimizes a
29
  required check away.
30
  """
31
32
  import Ecto.Query
33
34
  alias OpenAgents.Forge.Browse
35
  alias OpenAgents.Forge.GitPlane
36
  alias OpenAgents.Forge.Repos
37
  alias OpenAgents.Repo
38
  alias OpenAgents.Repositories.Repository
39
  alias OpenAgents.Stacks
40
  alias OpenAgents.Stacks.CheckRun
41
  alias OpenAgents.Stacks.Stack
42
43
  @workflows_path ".forge/workflows.json"
44
  @principal "stack-checks"
45
46
  @doc "The path of the workflow definitions file in the effective-base tree."
47
  def workflows_path, do: @workflows_path
48
49
  @doc """
50
  The workflow definitions governing checks at `base_oid`.
51
52
  Reads `#{inspect(".forge/workflows.json")}` from the given commit's tree and
53
  returns `{:ok, %{definition_oid: blob_oid, workflows: [workflow]}}` where
54
  each workflow has `name`, `run_on`, `required`, and `paths`. Returns
55
  `{:error, :no_workflows}` when the file is absent and
56
  `{:error, {:invalid_workflows, reason}}` when it does not parse into the
57
  declared policy shape.
58
  """
59
  def workflows(%Repository{} = repository, base_oid) do
60
    with {:ok, definition_oid} <- definition_oid(repository, base_oid),
61
         {:ok, blob} <- read_blob(repository, base_oid),
62
         {:ok, workflows} <- parse_workflows(blob.content) do
63
      {:ok, %{definition_oid: definition_oid, workflows: workflows}}
64
    end
65
  end
66
67
  @doc """
68
  Plan check runs for every active layer of `stack` against the current
69
  trunk tip.
70
71
  Marks runs whose effective base is no longer the trunk tip `stale`, then
72
  creates (or reuses, by identity) one run per layer and workflow that the
73
  declared `run_on` policy selects. Pass `context: "merge_group"` to plan
74
  the merge-group gate, where every workflow runs.
75
76
  Returns `{:ok, %{trunk_oid, definition_oid, runs, skipped, invalidated}}`
77
  where `skipped` lists each `(position, workflow)` the declared policy
78
  excluded — a skip is always explicit, never silent.
79
  """
80
  def plan(%Repository{} = repository, %Stack{} = stack, opts \\ []) do
81
    context = Keyword.get(opts, :context, "layer")
82
83
    with :ok <- check_context(context),
84
         {:ok, trunk_oid} <- resolve(repository, stack.trunk_ref),
85
         {:ok, %{definition_oid: definition_oid, workflows: workflows}} <-
86
           workflows(repository, trunk_oid) do
87
      entries = Stacks.active_entries(stack)
88
      invalidated = mark_stale(stack, trunk_oid)
89
90
      with {:ok, snapshots} <- snapshots(repository, entries, trunk_oid),
91
           {:ok, runs, skipped} <-
92
             materialize(repository, stack, entries, workflows, snapshots, %{
93
               context: context,
94
               trunk_oid: trunk_oid,
95
               definition_oid: definition_oid
96
             }) do
97
        {:ok,
98
         %{
99
           trunk_oid: trunk_oid,
100
           definition_oid: definition_oid,
101
           runs: runs,
102
           skipped: skipped,
103
           invalidated: invalidated
104
         }}
105
      end
106
    end
107
  end
108
109
  @doc """
110
  Mark every non-concluded run of `stack` whose effective base is not the
111
  current trunk tip as `stale`, so a trunk advance invalidates affected
112
  checks. Returns `{:ok, %{trunk_oid, invalidated}}`.
113
  """
114
  def refresh(%Repository{} = repository, %Stack{} = stack) do
115
    with {:ok, trunk_oid} <- resolve(repository, stack.trunk_ref) do
116
      {:ok, %{trunk_oid: trunk_oid, invalidated: mark_stale(stack, trunk_oid)}}
117
    end
118
  end
119
120
  @doc "Concludes a pending run as `passed` or `failed`."
121
  def report(%CheckRun{state: "pending"} = run, state) when state in ~w(passed failed) do
122
    Repo.update(CheckRun.conclusion_changeset(run, state, DateTime.utc_now()))
123
  end
124
125
  def report(%CheckRun{}, state) when state in ~w(passed failed),
126
    do: {:error, :not_pending}
127
128
  @doc "All runs of a stack, bottom layer first, newest identity last."
129
  def runs(%Stack{id: stack_id}) do
130
    Repo.all(
131
      from run in CheckRun,
132
        where: run.stack_id == ^stack_id,
133
        order_by: [asc: run.inserted_at, asc: run.id]
134
    )
135
  end
136
137
  ## Workflow definitions
138
139
  defp definition_oid(repository, base_oid) do
140
    path = Repos.bare_path(repository.storage_key)
141
142
    case Repos.git(path, ["rev-parse", "--verify", "--quiet", base_oid <> ":" <> @workflows_path]) do
143
      {output, 0} -> {:ok, String.trim(output)}
144
      _other -> {:error, :no_workflows}
145
    end
146
  end
147
148
  defp read_blob(repository, base_oid) do
149
    case Browse.blob(repository, base_oid, @workflows_path) do
150
      {:ok, blob} -> {:ok, blob}
151
      _other -> {:error, :no_workflows}
152
    end
153
  end
154
155
  defp parse_workflows(content) do
156
    case Jason.decode(content) do
157
      {:ok, %{"workflows" => workflows}} when is_list(workflows) ->
158
        parse_each(workflows, [])
159
160
      {:ok, _other} ->
161
        {:error, {:invalid_workflows, :missing_workflows_list}}
162
163
      {:error, _reason} ->
164
        {:error, {:invalid_workflows, :malformed_json}}
165
    end
166
  end
167
168
  defp parse_each([], parsed), do: {:ok, Enum.reverse(parsed)}
169
170
  defp parse_each([workflow | rest], parsed) do
171
    case parse_workflow(workflow) do
172
      {:ok, parsed_workflow} -> parse_each(rest, [parsed_workflow | parsed])
173
      {:error, reason} -> {:error, {:invalid_workflows, reason}}
174
    end
175
  end
176
177
  defp parse_workflow(%{"name" => name, "run_on" => run_on} = workflow)
178
       when is_binary(name) and name != "" do
179
    cond do
180
      run_on not in CheckRun.run_on_policies() ->
181
        {:error, {:unknown_run_on, name, run_on}}
182
183
      run_on == "changed_paths" and not valid_paths?(Map.get(workflow, "paths")) ->
184
        {:error, {:missing_paths, name}}
185
186
      not is_boolean(Map.get(workflow, "required", false)) ->
187
        {:error, {:invalid_required, name}}
188
189
      true ->
190
        {:ok,
191
         %{
192
           name: name,
193
           run_on: run_on,
194
           required: Map.get(workflow, "required", false),
195
           paths: Map.get(workflow, "paths", [])
196
         }}
197
    end
198
  end
199
200
  defp parse_workflow(_other), do: {:error, :invalid_workflow_entry}
201
202
  defp valid_paths?(paths) do
203
    is_list(paths) and paths != [] and Enum.all?(paths, &(is_binary(&1) and &1 != ""))
204
  end
205
206
  ## Run selection
207
208
  defp check_context(context) when context in ~w(layer merge_group), do: :ok
209
  defp check_context(_other), do: {:error, :invalid_context}
210
211
  # In the merge-group context every workflow runs: the merge group is the
212
  # authoritative gate. In the layer context the declared run_on policy
213
  # selects layers, and changed_paths consults the layer's own diff.
214
  defp selects?(_workflow, _entry, _top, "merge_group", _changed), do: true
215
216
  defp selects?(workflow, entry, top, "layer", changed) do
217
    case workflow.run_on do
218
      "every_layer" -> true
219
      "top_layer_only" -> entry.position == top
220
      "bottom_layer_only" -> entry.position == 1
221
      "changed_paths" -> Enum.any?(changed, &path_selected?(&1, workflow.paths))
222
      "merge_group_only" -> false
223
    end
224
  end
225
226
  # A declared path selects a changed file when it names the file exactly
227
  # or is a directory prefix.
228
  defp path_selected?(changed_path, declared_paths) do
229
    Enum.any?(declared_paths, fn declared ->
230
      prefix = String.trim_trailing(declared, "/")
231
      changed_path == prefix or String.starts_with?(changed_path, prefix <> "/")
232
    end)
233
  end
234
235
  defp changed_paths(repository, entry) do
236
    path = Repos.bare_path(repository.storage_key)
237
238
    args = [
239
      "diff",
240
      "--name-only",
241
      "--end-of-options",
242
      entry.boundary_oid,
243
      entry.observed_head_oid
244
    ]
245
246
    case Repos.git(path, args) do
247
      {output, 0} -> {:ok, String.split(output, "\n", trim: true)}
248
      _other -> {:error, {:diff_failed, entry.position}}
249
    end
250
  end
251
252
  ## Snapshots
253
254
  # Each snapshot is the cumulative repository state through a layer:
255
  # current trunk plus every layer up to and including this position. A
256
  # head that already sits on the chain (its boundary is the previous
257
  # snapshot) is its own snapshot; otherwise the layer replays onto the
258
  # chain and the result is published as a synthetic commit.
259
  defp snapshots(repository, entries, trunk_oid) do
260
    entries
261
    |> Enum.reduce_while({:ok, trunk_oid, %{}}, fn entry, {:ok, parent, acc} ->
262
      case snapshot_for(repository, entry, parent) do
263
        {:ok, snapshot} ->
264
          {:cont, {:ok, snapshot.tested_oid, Map.put(acc, entry.position, snapshot)}}
265
266
        {:error, reason} ->
267
          {:halt, {:error, reason}}
268
      end
269
    end)
270
    |> case do
271
      {:ok, _parent, snapshots} -> {:ok, snapshots}
272
      {:error, reason} -> {:error, reason}
273
    end
274
  end
275
276
  defp snapshot_for(_repository, %{boundary_oid: boundary} = entry, parent)
277
       when boundary == parent do
278
    {:ok, %{tested_oid: entry.observed_head_oid, synthetic: false}}
279
  end
280
281
  defp snapshot_for(repository, entry, parent) do
282
    case GitPlane.replay(
283
           repository.storage_key,
284
           entry.boundary_oid,
285
           entry.observed_head_oid,
286
           parent
287
         ) do
288
      {:ok, %{new_head: new_head}} ->
289
        {:ok, %{tested_oid: new_head, synthetic: true}}
290
291
      {:conflict, conflict} ->
292
        {:error, {:snapshot_conflict, entry.position, Map.get(conflict, :paths, [])}}
293
294
      {:error, reason} ->
295
        {:error, {:snapshot_failed, entry.position, reason}}
296
    end
297
  end
298
299
  ## Materialization
300
301
  defp materialize(repository, stack, entries, workflows, snapshots, plan) do
302
    top = entries |> Enum.map(& &1.position) |> Enum.max()
303
304
    entries
305
    |> Enum.reduce_while({:ok, [], []}, fn entry, {:ok, runs, skipped} ->
306
      case layer_runs(repository, stack, entry, workflows, snapshots, plan, top) do
307
        {:ok, layer_runs, layer_skipped} ->
308
          {:cont, {:ok, runs ++ layer_runs, skipped ++ layer_skipped}}
309
310
        {:error, reason} ->
311
          {:halt, {:error, reason}}
312
      end
313
    end)
314
    |> case do
315
      {:ok, runs, skipped} -> {:ok, runs, skipped}
316
      {:error, reason} -> {:error, reason}
317
    end
318
  end
319
320
  defp layer_runs(repository, stack, entry, workflows, snapshots, plan, top) do
321
    with {:ok, changed} <- layer_changed_paths(repository, entry, workflows, plan.context) do
322
      workflows
323
      |> Enum.reduce_while({:ok, [], []}, fn workflow, {:ok, runs, skipped} ->
324
        if selects?(workflow, entry, top, plan.context, changed) do
325
          case ensure_run(repository, stack, entry, workflow, snapshots, plan) do
326
            {:ok, run} -> {:cont, {:ok, [run | runs], skipped}}
327
            {:error, reason} -> {:halt, {:error, reason}}
328
          end
329
        else
330
          skip = %{position: entry.position, workflow: workflow.name, policy: workflow.run_on}
331
          {:cont, {:ok, runs, [skip | skipped]}}
332
        end
333
      end)
334
      |> case do
335
        {:ok, runs, skipped} -> {:ok, Enum.reverse(runs), Enum.reverse(skipped)}
336
        {:error, reason} -> {:error, reason}
337
      end
338
    end
339
  end
340
341
  defp layer_changed_paths(repository, entry, workflows, "layer") do
342
    if Enum.any?(workflows, &(&1.run_on == "changed_paths")) do
343
      changed_paths(repository, entry)
344
    else
345
      {:ok, []}
346
    end
347
  end
348
349
  defp layer_changed_paths(_repository, _entry, _workflows, _context), do: {:ok, []}
350
351
  defp ensure_run(repository, stack, entry, workflow, snapshots, plan) do
352
    snapshot = Map.fetch!(snapshots, entry.position)
353
354
    case existing_run(entry.pull_request_id, workflow.name, plan, entry.observed_head_oid) do
355
      %CheckRun{} = run -> {:ok, run}
356
      nil -> create_run(repository, stack, entry, workflow, snapshot, plan)
357
    end
358
  end
359
360
  defp existing_run(pull_request_id, workflow_name, plan, head_oid) do
361
    Repo.one(
362
      from run in CheckRun,
363
        where:
364
          run.pull_request_id == ^pull_request_id and
365
            run.workflow_name == ^workflow_name and
366
            run.context == ^plan.context and
367
            run.head_oid == ^head_oid and
368
            run.effective_base_oid == ^plan.trunk_oid and
369
            run.workflow_definition_oid == ^plan.definition_oid and
370
            run.state != "stale"
371
    )
372
  end
373
374
  defp create_run(repository, stack, entry, workflow, snapshot, plan) do
375
    run_id = Ecto.UUID.generate()
376
377
    synthetic_ref =
378
      if snapshot.synthetic do
379
        {:ok, ref} = GitPlane.internal_ref(["checks", run_id])
380
        ref
381
      end
382
383
    changeset =
384
      CheckRun.changeset(%CheckRun{}, %{
385
        id: run_id,
386
        repository_id: repository.id,
387
        stack_id: stack.id,
388
        pull_request_id: entry.pull_request_id,
389
        workflow_name: workflow.name,
390
        run_on: workflow.run_on,
391
        required: workflow.required,
392
        run_reason: "policy",
393
        context: plan.context,
394
        head_oid: entry.observed_head_oid,
395
        effective_base_oid: plan.trunk_oid,
396
        workflow_definition_oid: plan.definition_oid,
397
        tested_oid: snapshot.tested_oid,
398
        synthetic_ref: synthetic_ref
399
      })
400
401
    with {:ok, run} <- Repo.insert(changeset),
402
         :ok <- publish_synthetic_ref(repository, run) do
403
      {:ok, run}
404
    else
405
      {:error, %Ecto.Changeset{} = changeset} -> {:error, {:run_invalid, changeset}}
406
      {:error, reason} -> {:error, reason}
407
    end
408
  end
409
410
  # The synthetic ref publishes exactly once, from absent, and nothing ever
411
  # moves it: the snapshot a run tested is immutable for the run's lifetime.
412
  defp publish_synthetic_ref(_repository, %CheckRun{synthetic_ref: nil}), do: :ok
413
414
  defp publish_synthetic_ref(repository, %CheckRun{} = run) do
415
    updates = [%{ref: run.synthetic_ref, expected_old: :absent, new: run.tested_oid}]
416
417
    case GitPlane.batch_update_refs(repository.storage_key, updates, @principal) do
418
      {:ok, _result} -> :ok
419
      {:error, reason} -> {:error, {:synthetic_ref_failed, reason}}
420
    end
421
  end
422
423
  ## Invalidation
424
425
  defp mark_stale(stack, trunk_oid) do
426
    {count, _rows} =
427
      Repo.update_all(
428
        from(run in CheckRun,
429
          where:
430
            run.stack_id == ^stack.id and
431
              run.state in ["pending", "passed"] and
432
              run.effective_base_oid != ^trunk_oid
433
        ),
434
        set: [state: "stale", updated_at: DateTime.utc_now()]
435
      )
436
437
    count
438
  end
439
440
  defp resolve(repository, ref) do
441
    case Browse.resolve_commit(repository, ref) do
442
      {:ok, oid} -> {:ok, oid}
443
      _other -> {:error, {:unresolved_ref, ref}}
444
    end
445
  end
446
end
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -251,7 +251,8 @@

251 251
    20260823072000,
252 252
    20260823073000,
253 253
    20260823074000,
254
    20260823120247
254
    20260823120247,
255
    20260823122038
255 256
  ],
256 257
  "required_tables": [
257 258
    "users",
priv/repo/migrations/20260823122038_create_stack_check_runs.exs added +66

@@ -0,0 +1,66 @@

1
defmodule OpenAgents.Repo.Migrations.CreateStackCheckRuns do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:stack_check_runs, 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 :stack_id,
13
          references(:pull_request_stacks, type: :binary_id, on_delete: :delete_all),
14
          null: false
15
16
      add :pull_request_id,
17
          references(:pull_requests, type: :binary_id, on_delete: :delete_all),
18
          null: false
19
20
      add :workflow_name, :string, null: false
21
      add :run_on, :string, null: false
22
      add :required, :boolean, null: false, default: false
23
      add :run_reason, :string, null: false
24
      add :context, :string, null: false, default: "layer"
25
26
      add :head_oid, :binary, null: false
27
      add :effective_base_oid, :binary, null: false
28
      add :workflow_definition_oid, :binary, null: false
29
      add :tested_oid, :binary, null: false
30
      add :synthetic_ref, :string
31
32
      add :state, :string, null: false, default: "pending"
33
      add :concluded_at, :utc_datetime_usec
34
35
      timestamps(type: :utc_datetime_usec)
36
    end
37
38
    create unique_index(
39
             :stack_check_runs,
40
             [
41
               :pull_request_id,
42
               :workflow_name,
43
               :context,
44
               :head_oid,
45
               :effective_base_oid,
46
               :workflow_definition_oid
47
             ],
48
             name: :stack_check_runs_identity_index
49
           )
50
51
    create index(:stack_check_runs, [:stack_id, :state])
52
53
    create constraint(:stack_check_runs, :stack_check_runs_state_check,
54
             check: "state IN ('pending', 'passed', 'failed', 'stale')"
55
           )
56
57
    create constraint(:stack_check_runs, :stack_check_runs_context_check,
58
             check: "context IN ('layer', 'merge_group')"
59
           )
60
61
    create constraint(:stack_check_runs, :stack_check_runs_run_on_check,
62
             check:
63
               "run_on IN ('every_layer', 'top_layer_only', 'bottom_layer_only', 'changed_paths', 'merge_group_only')"
64
           )
65
  end
66
end
test/openagents/stacks/checks_test.exs added +494

@@ -0,0 +1,494 @@

1
defmodule OpenAgents.Stacks.ChecksTest do
2
  @moduledoc """
3
  Stack-aware checks (#53): full-context run identity, trunk-advance
4
  invalidation, immutable synthetic snapshot refs whose trees layer the
5
  current trunk plus every layer through the checked pull request, and
6
  every declared `run_on` policy.
7
  """
8
9
  use OpenAgents.DataCase, async: false
10
11
  alias OpenAgents.Forge.Repos
12
  alias OpenAgents.PullRequests.PullRequest
13
  alias OpenAgents.Repo
14
  alias OpenAgents.Stacks
15
  alias OpenAgents.Stacks.CheckRun
16
  alias OpenAgents.Stacks.Checks
17
18
  import OpenAgents.AccountsFixtures
19
  import OpenAgents.IssuesFixtures
20
21
  setup do
22
    base = Path.join(System.tmp_dir!(), "stack-checks-#{System.unique_integer([:positive])}")
23
24
    previous_data = Application.get_env(:openagents, :forge_data_dir)
25
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
26
    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
27
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
28
29
    on_exit(fn ->
30
      restore_env(:forge_data_dir, previous_data)
31
      restore_env(:forge_wal_dir, previous_wal)
32
      File.rm_rf(base)
33
    end)
34
35
    actor = repository_user_fixture("checks-actor")
36
    repository = repository_with_member_fixture(actor)
37
38
    %{actor: actor, repository: repository}
39
  end
40
41
  describe "workflow definitions" do
42
    test "an absent definitions file returns no_workflows", context do
43
      %{repository: repository, actor: actor} = context
44
      %{stack: stack} = seed_stack(repository, actor, workflows: nil)
45
46
      assert {:error, :no_workflows} = Checks.plan(repository, stack)
47
    end
48
49
    test "a malformed definitions file is rejected", context do
50
      %{repository: repository, actor: actor} = context
51
      %{stack: stack} = seed_stack(repository, actor, workflows: "not json")
52
53
      assert {:error, {:invalid_workflows, :malformed_json}} = Checks.plan(repository, stack)
54
    end
55
56
    test "an unknown run_on policy is rejected", context do
57
      %{repository: repository, actor: actor} = context
58
59
      %{stack: stack} =
60
        seed_stack(repository, actor,
61
          workflows: workflows_json([%{"name" => "unit", "run_on" => "sometimes"}])
62
        )
63
64
      assert {:error, {:invalid_workflows, {:unknown_run_on, "unit", "sometimes"}}} =
65
               Checks.plan(repository, stack)
66
    end
67
  end
68
69
  describe "run identity" do
70
    test "runs key to head, effective base, and workflow definition", context do
71
      %{repository: repository, actor: actor} = context
72
73
      %{stack: stack, oids: oids, pull_requests: [pr_1, pr_2]} =
74
        seed_stack(repository, actor, workflows: every_layer_workflows())
75
76
      {:ok, plan} = Checks.plan(repository, stack)
77
78
      assert plan.invalidated == 0
79
      assert plan.skipped == []
80
      assert length(plan.runs) == 2
81
82
      run_1 = Enum.find(plan.runs, &(&1.pull_request_id == pr_1.id))
83
      run_2 = Enum.find(plan.runs, &(&1.pull_request_id == pr_2.id))
84
85
      assert run_1.head_oid == oids["layer-1"]
86
      assert run_2.head_oid == oids["layer-2"]
87
      assert run_1.effective_base_oid == plan.trunk_oid
88
      assert run_2.effective_base_oid == plan.trunk_oid
89
      assert run_1.workflow_definition_oid == plan.definition_oid
90
      assert run_1.state == "pending"
91
92
      # A healthy stack's heads already contain every lower layer, so the
93
      # heads are their own snapshots and no synthetic ref publishes.
94
      assert run_1.tested_oid == oids["layer-1"]
95
      assert run_2.tested_oid == oids["layer-2"]
96
      assert is_nil(run_1.synthetic_ref)
97
      assert is_nil(run_2.synthetic_ref)
98
    end
99
100
    test "planning twice reuses runs instead of duplicating identity", context do
101
      %{repository: repository, actor: actor} = context
102
103
      %{stack: stack} = seed_stack(repository, actor, workflows: every_layer_workflows())
104
105
      {:ok, first} = Checks.plan(repository, stack)
106
      {:ok, second} = Checks.plan(repository, stack)
107
108
      assert Enum.map(first.runs, & &1.id) |> Enum.sort() ==
109
               Enum.map(second.runs, & &1.id) |> Enum.sort()
110
111
      assert Repo.aggregate(CheckRun, :count) == 2
112
    end
113
114
    test "a trunk advance invalidates existing runs and re-keys new ones", context do
115
      %{repository: repository, actor: actor} = context
116
117
      %{stack: stack, path: path, oids: oids} =
118
        seed_stack(repository, actor, workflows: every_layer_workflows())
119
120
      {:ok, before_advance} = Checks.plan(repository, stack)
121
      old_trunk = before_advance.trunk_oid
122
123
      advance = commit(path, oids["main"], "Advance trunk", %{"advance.md" => "advance\n"})
124
      {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", advance])
125
126
      {:ok, after_advance} = Checks.plan(repository, stack)
127
128
      assert after_advance.trunk_oid == advance
129
      assert after_advance.invalidated == 2
130
131
      for run <- before_advance.runs do
132
        assert Repo.get!(CheckRun, run.id).state == "stale"
133
      end
134
135
      for run <- after_advance.runs do
136
        assert run.effective_base_oid == advance
137
        assert run.effective_base_oid != old_trunk
138
        assert run.state == "pending"
139
      end
140
    end
141
142
    test "refresh alone marks affected runs stale after a trunk advance", context do
143
      %{repository: repository, actor: actor} = context
144
145
      %{stack: stack, path: path, oids: oids} =
146
        seed_stack(repository, actor, workflows: every_layer_workflows())
147
148
      {:ok, plan} = Checks.plan(repository, stack)
149
      {:ok, run} = Checks.report(hd(plan.runs), "passed")
150
      assert run.state == "passed"
151
152
      advance = commit(path, oids["main"], "Advance trunk", %{"advance.md" => "advance\n"})
153
      {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", advance])
154
155
      {:ok, %{trunk_oid: ^advance, invalidated: 2}} = Checks.refresh(repository, stack)
156
157
      assert Repo.get!(CheckRun, run.id).state == "stale"
158
    end
159
  end
160
161
  describe "synthetic snapshot refs" do
162
    test "a stack behind trunk tests a synthetic snapshot of trunk plus its layers",
163
         context do
164
      %{repository: repository, actor: actor} = context
165
166
      %{stack: stack, path: path, oids: oids, pull_requests: [pr_1, pr_2]} =
167
        seed_stack(repository, actor, workflows: every_layer_workflows())
168
169
      advance = commit(path, oids["main"], "Advance trunk", %{"advance.md" => "advance\n"})
170
      {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", advance])
171
172
      {:ok, plan} = Checks.plan(repository, stack)
173
174
      run_1 = Enum.find(plan.runs, &(&1.pull_request_id == pr_1.id))
175
      run_2 = Enum.find(plan.runs, &(&1.pull_request_id == pr_2.id))
176
177
      for run <- [run_1, run_2] do
178
        assert run.synthetic_ref == "refs/internal/checks/" <> run.id
179
        assert run.tested_oid != run.head_oid
180
        assert show(path, ["rev-parse", run.synthetic_ref]) == run.tested_oid
181
      end
182
183
      # The snapshot tree is the current trunk plus every layer through the
184
      # checked pull request.
185
      names_1 = tree_names(path, run_1.tested_oid)
186
      assert "advance.md" in names_1
187
      assert "layer-1.md" in names_1
188
      refute "layer-2.md" in names_1
189
190
      names_2 = tree_names(path, run_2.tested_oid)
191
      assert "advance.md" in names_2
192
      assert "layer-1.md" in names_2
193
      assert "layer-2.md" in names_2
194
    end
195
196
    test "a synthetic ref publishes once and never moves", context do
197
      %{repository: repository, actor: actor} = context
198
199
      %{stack: stack, path: path, oids: oids} =
200
        seed_stack(repository, actor, workflows: every_layer_workflows())
201
202
      advance = commit(path, oids["main"], "Advance trunk", %{"advance.md" => "advance\n"})
203
      {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", advance])
204
205
      {:ok, first} = Checks.plan(repository, stack)
206
      {:ok, second} = Checks.plan(repository, stack)
207
208
      assert Enum.map(first.runs, & &1.id) |> Enum.sort() ==
209
               Enum.map(second.runs, & &1.id) |> Enum.sort()
210
211
      for run <- second.runs do
212
        assert show(path, ["rev-parse", run.synthetic_ref]) == run.tested_oid
213
        assert Repo.get!(CheckRun, run.id).tested_oid == run.tested_oid
214
      end
215
    end
216
  end
217
218
  describe "run_on policies" do
219
    test "top_layer_only plans only the top layer, with explicit skips", context do
220
      %{repository: repository, actor: actor} = context
221
222
      %{stack: stack, pull_requests: [_pr_1, pr_2]} =
223
        seed_stack(repository, actor,
224
          workflows: workflows_json([%{"name" => "e2e", "run_on" => "top_layer_only"}])
225
        )
226
227
      {:ok, plan} = Checks.plan(repository, stack)
228
229
      assert [run] = plan.runs
230
      assert run.pull_request_id == pr_2.id
231
      assert plan.skipped == [%{position: 1, workflow: "e2e", policy: "top_layer_only"}]
232
    end
233
234
    test "bottom_layer_only plans only the bottom layer", context do
235
      %{repository: repository, actor: actor} = context
236
237
      %{stack: stack, pull_requests: [pr_1, _pr_2]} =
238
        seed_stack(repository, actor,
239
          workflows: workflows_json([%{"name" => "smoke", "run_on" => "bottom_layer_only"}])
240
        )
241
242
      {:ok, plan} = Checks.plan(repository, stack)
243
244
      assert [run] = plan.runs
245
      assert run.pull_request_id == pr_1.id
246
      assert plan.skipped == [%{position: 2, workflow: "smoke", policy: "bottom_layer_only"}]
247
    end
248
249
    test "changed_paths plans only layers that touch the declared paths", context do
250
      %{repository: repository, actor: actor} = context
251
252
      %{stack: stack, pull_requests: [pr_1, _pr_2]} =
253
        seed_stack(repository, actor,
254
          workflows:
255
            workflows_json([
256
              %{
257
                "name" => "layer-1-only",
258
                "run_on" => "changed_paths",
259
                "paths" => ["layer-1.md"]
260
              }
261
            ])
262
        )
263
264
      {:ok, plan} = Checks.plan(repository, stack)
265
266
      assert [run] = plan.runs
267
      assert run.pull_request_id == pr_1.id
268
269
      assert plan.skipped == [
270
               %{position: 2, workflow: "layer-1-only", policy: "changed_paths"}
271
             ]
272
    end
273
274
    test "merge_group_only skips every layer outside a merge group", context do
275
      %{repository: repository, actor: actor} = context
276
277
      %{stack: stack} =
278
        seed_stack(repository, actor,
279
          workflows:
280
            workflows_json([
281
              %{"name" => "queue-gate", "run_on" => "merge_group_only", "required" => true}
282
            ])
283
        )
284
285
      {:ok, plan} = Checks.plan(repository, stack)
286
287
      assert plan.runs == []
288
289
      assert plan.skipped == [
290
               %{position: 1, workflow: "queue-gate", policy: "merge_group_only"},
291
               %{position: 2, workflow: "queue-gate", policy: "merge_group_only"}
292
             ]
293
    end
294
295
    test "the merge_group context runs every workflow: required checks never skip",
296
         context do
297
      %{repository: repository, actor: actor} = context
298
299
      %{stack: stack} =
300
        seed_stack(repository, actor,
301
          workflows:
302
            workflows_json([
303
              %{"name" => "queue-gate", "run_on" => "merge_group_only", "required" => true},
304
              %{"name" => "e2e", "run_on" => "top_layer_only", "required" => true}
305
            ])
306
        )
307
308
      {:ok, plan} = Checks.plan(repository, stack, context: "merge_group")
309
310
      assert plan.skipped == []
311
      assert length(plan.runs) == 4
312
      assert Enum.all?(plan.runs, &(&1.context == "merge_group"))
313
314
      # Layer-context runs and merge-group runs have distinct identities.
315
      {:ok, layer_plan} = Checks.plan(repository, stack)
316
      assert [%{workflow_name: "e2e", context: "layer"}] = layer_plan.runs
317
    end
318
  end
319
320
  describe "reporting" do
321
    test "a pending run concludes once", context do
322
      %{repository: repository, actor: actor} = context
323
324
      %{stack: stack} = seed_stack(repository, actor, workflows: every_layer_workflows())
325
326
      {:ok, plan} = Checks.plan(repository, stack)
327
      [run | _rest] = plan.runs
328
329
      {:ok, passed} = Checks.report(run, "passed")
330
      assert passed.state == "passed"
331
      refute is_nil(passed.concluded_at)
332
333
      assert {:error, :not_pending} = Checks.report(passed, "failed")
334
    end
335
  end
336
337
  ## Seeding
338
339
  defp every_layer_workflows do
340
    workflows_json([%{"name" => "unit", "run_on" => "every_layer", "required" => true}])
341
  end
342
343
  defp workflows_json(workflows), do: Jason.encode!(%{"workflows" => workflows})
344
345
  defp seed_stack(repository, actor, opts) do
346
    path = Repos.ensure_repo!(repository.storage_key, repository.default_branch)
347
    branches = ["layer-1", "layer-2"]
348
349
    seed = commit(path, nil, "Seed repository", %{"README.md" => "readme\n"})
350
351
    main =
352
      case Keyword.fetch!(opts, :workflows) do
353
        nil -> seed
354
        json -> commit_workflows(path, seed, json)
355
      end
356
357
    {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", main])
358
359
    {oids, _parent} =
360
      Enum.reduce(branches, {%{"main" => main}, main}, fn branch, {oids, parent} ->
361
        oid = commit(path, parent, "Layer #{branch}", %{"#{branch}.md" => "#{branch}\n"})
362
        {_, 0} = Repos.git(path, ["update-ref", "refs/heads/#{branch}", oid])
363
        {Map.put(oids, branch, oid), oid}
364
      end)
365
366
    pull_requests =
367
      branches
368
      |> Enum.with_index()
369
      |> Enum.map(fn {branch, index} ->
370
        base = Enum.at(["main" | branches], index)
371
        pull_request(repository, branch, base, oids[base], oids[branch])
372
      end)
373
374
    {:ok, stack} = Stacks.create(repository, pull_requests, actor)
375
376
    %{path: path, oids: oids, stack: stack, pull_requests: pull_requests}
377
  end
378
379
  # Commits `.forge/workflows.json` as a nested tree over the parent's tree.
380
  defp commit_workflows(path, parent, json) do
381
    blob = git!(path, ["hash-object", "-w", "--stdin"], json)
382
    forge_tree = git!(path, ["mktree"], "100644 blob #{blob}\tworkflows.json\n")
383
384
    {listing, 0} = Repos.git(path, ["ls-tree", parent])
385
386
    entries =
387
      listing
388
      |> String.split("\n", trim: true)
389
      |> Enum.reject(&String.ends_with?(&1, "\t.forge"))
390
      |> Enum.concat(["040000 tree #{forge_tree}\t.forge"])
391
392
    tree = git!(path, ["mktree"], Enum.map_join(entries, "", &(&1 <> "\n")))
393
394
    git!(path, ["commit-tree", tree, "-p", parent, "-m", "Declare workflows"], "",
395
      env: committer_env()
396
    )
397
  end
398
399
  defp pull_request(repository, head_ref, base_ref, base_sha, head_sha) do
400
    issue = issue_fixture(repository, %{title: "PR #{head_ref}"})
401
402
    {:ok, pull_request} =
403
      %PullRequest{}
404
      |> PullRequest.changeset(%{
405
        repository_id: repository.id,
406
        issue_id: issue.id,
407
        head_repository_id: repository.id,
408
        head_ref: head_ref,
409
        head_sha: head_sha,
410
        base_ref: base_ref,
411
        base_sha: base_sha,
412
        state: "open"
413
      })
414
      |> Repo.insert()
415
416
    Repo.preload(pull_request, :issue)
417
  end
418
419
  # Commits a tree that layers the given files over the parent's tree.
420
  defp commit(path, parent, message, files) do
421
    parent_entries =
422
      if parent do
423
        {listing, 0} = Repos.git(path, ["ls-tree", parent])
424
425
        listing
426
        |> String.split("\n", trim: true)
427
        |> Map.new(fn line ->
428
          [meta, name] = String.split(line, "\t", parts: 2)
429
          {name, meta <> "\t" <> name}
430
        end)
431
      else
432
        %{}
433
      end
434
435
    new_entries =
436
      Map.new(files, fn {name, content} ->
437
        blob = git!(path, ["hash-object", "-w", "--stdin"], content)
438
        {name, "100644 blob #{blob}\t#{name}"}
439
      end)
440
441
    listing =
442
      parent_entries
443
      |> Map.merge(new_entries)
444
      |> Map.values()
445
      |> Enum.map_join("", &(&1 <> "\n"))
446
447
    tree = git!(path, ["mktree"], listing)
448
    parent_args = if parent, do: ["-p", parent], else: []
449
450
    git!(path, ["commit-tree", tree] ++ parent_args ++ ["-m", message], "", env: committer_env())
451
  end
452
453
  defp committer_env do
454
    [
455
      {"GIT_AUTHOR_NAME", "Test Author"},
456
      {"GIT_AUTHOR_EMAIL", "author@example.test"},
457
      {"GIT_COMMITTER_NAME", "Test Author"},
458
      {"GIT_COMMITTER_EMAIL", "author@example.test"}
459
    ]
460
  end
461
462
  defp show(path, args) do
463
    {output, 0} = Repos.git(path, args)
464
    String.trim(output)
465
  end
466
467
  defp tree_names(path, commit_oid) do
468
    {listing, 0} = Repos.git(path, ["ls-tree", "-r", "--name-only", commit_oid])
469
    String.split(listing, "\n", trim: true)
470
  end
471
472
  defp git!(git_dir, args, input, options \\ []) do
473
    input_path =
474
      Path.join(System.tmp_dir!(), "checks-input-#{System.unique_integer([:positive])}")
475
476
    File.write!(input_path, input)
477
478
    try do
479
      {output, 0} =
480
        System.cmd(
481
          "sh",
482
          ["-c", ~s(exec git --git-dir "$GIT_DIR" "$@" < "$INPUT"), "sh"] ++ args,
483
          env: [{"GIT_DIR", git_dir}, {"INPUT", input_path}] ++ Keyword.get(options, :env, [])
484
        )
485
486
      String.trim(output)
487
    after
488
      File.rm(input_path)
489
    end
490
  end
491
492
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
493
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
494
end

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