|
1
|
+ |
defmodule OpenAgents.Transparency.WorkDisclosureTest do
|
|
2
|
+ |
@moduledoc """
|
|
3
|
+ |
Stage 5 of `#10`: transparency tiers over work in progress.
|
|
4
|
+ |
|
|
5
|
+ |
Three properties, and the first is the one that makes the other two mean
|
|
6
|
+ |
anything.
|
|
7
|
+ |
|
|
8
|
+ |
**The schedule is exhaustive.** Every column of `forge_assignments`,
|
|
9
|
+ |
`work_jobs`, and `issue_evidence` is either the source of exactly one
|
|
10
|
+ |
scheduled field or a member of that family's never list. A new column is a
|
|
11
|
+ |
failure here until somebody decides which, so the schedule cannot quietly
|
|
12
|
+ |
fall behind the schema it describes.
|
|
13
|
+ |
|
|
14
|
+ |
**The rungs discriminate on a repository nothing else gates.** Every tier
|
|
15
|
+ |
assertion below runs on a *public* repository, where
|
|
16
|
+ |
`Repositories.readable_by/2` admits everybody and the tier is the only thing
|
|
17
|
+ |
between an anonymous reader and a branch, a revision, a receipt handle, an
|
|
18
|
+ |
environment, and a report. A private repository would have proved the tier
|
|
19
|
+ |
worked when the repository gate was doing the work.
|
|
20
|
+ |
|
|
21
|
+ |
**Repository authority is still stronger.** A record whose tier is `glass`
|
|
22
|
+ |
in a repository that went private is invisible to a non-member. That case is
|
|
23
|
+ |
the one the disclosure dial does not cover and the tier cannot override.
|
|
24
|
+ |
"""
|
|
25
|
+ |
use OpenAgents.DataCase, async: true
|
|
26
|
+ |
|
|
27
|
+ |
import Ecto.Query
|
|
28
|
+ |
import OpenAgents.AccountsFixtures
|
|
29
|
+ |
|
|
30
|
+ |
alias OpenAgents.Forge.Assignment
|
|
31
|
+ |
alias OpenAgents.Issues
|
|
32
|
+ |
alias OpenAgents.Issues.{Evidence, EvidenceEntry}
|
|
33
|
+ |
alias OpenAgents.Forge.Assignments
|
|
34
|
+ |
alias OpenAgents.Repo
|
|
35
|
+ |
alias OpenAgents.Repositories
|
|
36
|
+ |
alias OpenAgents.Transparency
|
|
37
|
+ |
alias OpenAgents.Transparency.{ArtifactLink, WorkDisclosure}
|
|
38
|
+ |
alias OpenAgents.Work.Job
|
|
39
|
+ |
|
|
40
|
+ |
@sha String.duplicate("ab", 20)
|
|
41
|
+ |
|
|
42
|
+ |
setup do
|
|
43
|
+ |
owner = repository_user_fixture("work-owner")
|
|
44
|
+ |
member = repository_user_fixture("work-member")
|
|
45
|
+ |
stranger = repository_user_fixture("work-stranger")
|
|
46
|
+ |
|
|
47
|
+ |
repository = repository_with_member_fixture(owner, %{visibility: "public"}, "owner")
|
|
48
|
+ |
{:ok, _} = Repositories.add_member(repository, member, "maintainer")
|
|
49
|
+ |
|
|
50
|
+ |
{:ok, issue} = Issues.create_issue(repository, %{title: "Do the work"})
|
|
51
|
+ |
|
|
52
|
+ |
%{
|
|
53
|
+ |
owner: owner,
|
|
54
|
+ |
member: member,
|
|
55
|
+ |
stranger: stranger,
|
|
56
|
+ |
repository: repository,
|
|
57
|
+ |
issue: issue
|
|
58
|
+ |
}
|
|
59
|
+ |
end
|
|
60
|
+ |
|
|
61
|
+ |
# ── the schedule is exhaustive ──────────────────────────────────────────
|
|
62
|
+ |
|
|
63
|
+ |
describe "the schedule covers every column of every record it describes" do
|
|
64
|
+ |
@schema_for %{
|
|
65
|
+ |
attempt: Assignment,
|
|
66
|
+ |
work_job: Job,
|
|
67
|
+ |
evidence: EvidenceEntry
|
|
68
|
+ |
}
|
|
69
|
+ |
|
|
70
|
+ |
for {family, schema} <- @schema_for do
|
|
71
|
+ |
test "#{family}: every column is scheduled or in the never list" do
|
|
72
|
+ |
family = unquote(family)
|
|
73
|
+ |
schema = unquote(schema)
|
|
74
|
+ |
|
|
75
|
+ |
columns = MapSet.new(schema.__schema__(:fields))
|
|
76
|
+ |
scheduled = MapSet.new(WorkDisclosure.source_columns(family))
|
|
77
|
+ |
never = MapSet.new(WorkDisclosure.never(family))
|
|
78
|
+ |
|
|
79
|
+ |
unclassified = columns |> MapSet.difference(scheduled) |> MapSet.difference(never)
|
|
80
|
+ |
|
|
81
|
+ |
assert MapSet.to_list(unclassified) == [],
|
|
82
|
+ |
"#{family} columns in neither the schedule nor the never list: " <>
|
|
83
|
+ |
inspect(MapSet.to_list(unclassified))
|
|
84
|
+ |
|
|
85
|
+ |
both = MapSet.intersection(scheduled, never)
|
|
86
|
+ |
|
|
87
|
+ |
assert MapSet.to_list(both) == [],
|
|
88
|
+ |
"#{family} columns both scheduled and never: " <> inspect(MapSet.to_list(both))
|
|
89
|
+ |
end
|
|
90
|
+ |
end
|
|
91
|
+ |
|
|
92
|
+ |
test "every scheduled field names a real tier, and the ladder is monotone" do
|
|
93
|
+ |
for family <- WorkDisclosure.families() do
|
|
94
|
+ |
for {field, tier} <- WorkDisclosure.schedule()[family] do
|
|
95
|
+ |
assert tier in [:pulse, :ledger, :glass],
|
|
96
|
+ |
"#{family}.#{field} is on no rung this ladder has"
|
|
97
|
+ |
end
|
|
98
|
+ |
|
|
99
|
+ |
assert WorkDisclosure.fields_at(family, :dark) == []
|
|
100
|
+ |
|
|
101
|
+ |
pulse = MapSet.new(WorkDisclosure.fields_at(family, :pulse))
|
|
102
|
+ |
ledger = MapSet.new(WorkDisclosure.fields_at(family, :ledger))
|
|
103
|
+ |
glass = MapSet.new(WorkDisclosure.fields_at(family, :glass))
|
|
104
|
+ |
|
|
105
|
+ |
assert MapSet.subset?(pulse, ledger)
|
|
106
|
+ |
assert MapSet.subset?(ledger, glass)
|
|
107
|
+ |
end
|
|
108
|
+ |
end
|
|
109
|
+ |
|
|
110
|
+ |
test "the work vocabulary is in the artifact type list and `trace` has no producer" do
|
|
111
|
+ |
types = ArtifactLink.artifact_types()
|
|
112
|
+ |
|
|
113
|
+ |
for member <- ~w(attempt work_job deployment trace) do
|
|
114
|
+ |
assert member in types
|
|
115
|
+ |
end
|
|
116
|
+ |
|
|
117
|
+ |
# `#149` asks for a `trace` member and nothing in this repository
|
|
118
|
+ |
# produces a trace artifact. Naming that here keeps the vocabulary from
|
|
119
|
+ |
# reading as a shipped surface.
|
|
120
|
+ |
producing =
|
|
121
|
+ |
"lib/**/*.ex"
|
|
122
|
+ |
|> Path.wildcard()
|
|
123
|
+ |
|> Enum.flat_map(&Regex.scan(~r/artifact_type:\s*"(\w+)"/, File.read!(&1)))
|
|
124
|
+ |
|> Enum.map(&Enum.at(&1, 1))
|
|
125
|
+ |
|> Enum.uniq()
|
|
126
|
+ |
|
|
127
|
+ |
refute "trace" in producing
|
|
128
|
+ |
assert "attempt" in producing
|
|
129
|
+ |
end
|
|
130
|
+ |
end
|
|
131
|
+ |
|
|
132
|
+ |
# ── the rungs, on a public repository ───────────────────────────────────
|
|
133
|
+ |
|
|
134
|
+ |
describe "an attempt on a public repository" do
|
|
135
|
+ |
test "tells an anonymous reader that work ran, and no ref, revision, or reason",
|
|
136
|
+ |
context do
|
|
137
|
+ |
attempt(context, %{state: "completed", terminal_commit: @sha})
|
|
138
|
+ |
|
|
139
|
+ |
assert [projection] = read_attempts(context, nil)
|
|
140
|
+ |
|
|
141
|
+ |
assert Enum.sort(Map.keys(projection)) ==
|
|
142
|
+ |
Enum.sort([:work_job | WorkDisclosure.fields_at(:attempt, :pulse)])
|
|
143
|
+ |
|
|
144
|
+ |
assert projection.state == "completed"
|
|
145
|
+ |
assert projection.target_kind == "box"
|
|
146
|
+ |
assert projection.requester_kind == "user"
|
|
147
|
+ |
|
|
148
|
+ |
refute Map.has_key?(projection, :branch)
|
|
149
|
+ |
refute Map.has_key?(projection, :terminal_branch)
|
|
150
|
+ |
refute Map.has_key?(projection, :terminal_commit)
|
|
151
|
+ |
refute Map.has_key?(projection, :failure_reason)
|
|
152
|
+ |
end
|
|
153
|
+ |
|
|
154
|
+ |
test "gives a signed-in stranger exactly what it gives anonymous traffic", context do
|
|
155
|
+ |
attempt(context, %{state: "completed", terminal_commit: @sha})
|
|
156
|
+ |
|
|
157
|
+ |
assert read_attempts(context, nil) == read_attempts(context, context.stranger)
|
|
158
|
+ |
end
|
|
159
|
+ |
|
|
160
|
+ |
test "gives a repository member the branch, the revision, and the reason", context do
|
|
161
|
+ |
attempt(context, %{state: "failed", failure_reason: "assignment_expired"})
|
|
162
|
+ |
|
|
163
|
+ |
assert [projection] = read_attempts(context, context.member)
|
|
164
|
+ |
|
|
165
|
+ |
assert Enum.sort(Map.keys(projection)) ==
|
|
166
|
+ |
Enum.sort([:work_job | WorkDisclosure.fields_at(:attempt, :ledger)])
|
|
167
|
+ |
|
|
168
|
+ |
assert projection.branch == "agent/issue-1"
|
|
169
|
+ |
assert projection.failure_reason == "assignment_expired"
|
|
170
|
+ |
end
|
|
171
|
+ |
|
|
172
|
+ |
test "never publishes the requesting principal, only its kind", context do
|
|
173
|
+ |
%{assignment: assignment} = attempt(context, %{})
|
|
174
|
+ |
|
|
175
|
+ |
for reader <- [nil, context.stranger, context.member, context.owner] do
|
|
176
|
+ |
assert [projection] = read_attempts(context, reader)
|
|
177
|
+ |
assert projection.requester_kind in ["user", "agent"]
|
|
178
|
+ |
|
|
179
|
+ |
values = projection |> Map.values() |> Enum.map(&inspect/1) |> Enum.join(" ")
|
|
180
|
+ |
refute values =~ assignment.requesting_principal["id"]
|
|
181
|
+ |
refute values =~ "actor_id"
|
|
182
|
+ |
end
|
|
183
|
+ |
end
|
|
184
|
+ |
end
|
|
185
|
+ |
|
|
186
|
+ |
describe "the work job behind an attempt" do
|
|
187
|
+ |
test "reaches a member as counts and bounds, and never as a report", context do
|
|
188
|
+ |
attempt(context, %{}, job: true)
|
|
189
|
+ |
|
|
190
|
+ |
assert [%{work_job: job}] = read_attempts(context, context.member)
|
|
191
|
+ |
|
|
192
|
+ |
assert Enum.sort(Map.keys(job)) == Enum.sort(WorkDisclosure.fields_at(:work_job, :ledger))
|
|
193
|
+ |
|
|
194
|
+ |
assert job.tool_call_count == 7
|
|
195
|
+ |
assert job.budget == %{"wall_clock_ms" => 60_000, "maximum_report_bytes" => 8_000}
|
|
196
|
+ |
|
|
197
|
+ |
refute Map.has_key?(job, :report)
|
|
198
|
+ |
refute Map.has_key?(job, :usage)
|
|
199
|
+ |
refute Map.has_key?(job, :model_id)
|
|
200
|
+ |
end
|
|
201
|
+ |
|
|
202
|
+ |
test "reaches the account the work belongs to as its report", context do
|
|
203
|
+ |
attempt(context, %{}, job: true)
|
|
204
|
+ |
|
|
205
|
+ |
assert [%{work_job: job}] = read_attempts(context, context.owner)
|
|
206
|
+ |
|
|
207
|
+ |
assert Enum.sort(Map.keys(job)) == Enum.sort(WorkDisclosure.fields_at(:work_job, :glass))
|
|
208
|
+ |
assert job.report == "Renamed the billing column and pushed."
|
|
209
|
+ |
assert job.model_id == "test-model"
|
|
210
|
+ |
end
|
|
211
|
+ |
|
|
212
|
+ |
test "never publishes the goal, the prompt, or the authority snapshot", context do
|
|
213
|
+ |
attempt(context, %{}, job: true)
|
|
214
|
+ |
|
|
215
|
+ |
for reader <- [nil, context.stranger, context.member, context.owner] do
|
|
216
|
+ |
assert [%{work_job: job}] = read_attempts(context, reader)
|
|
217
|
+ |
|
|
218
|
+ |
rendered = inspect(job)
|
|
219
|
+ |
refute rendered =~ "SECRET-GOAL"
|
|
220
|
+ |
refute rendered =~ "SECRET-PROMPT"
|
|
221
|
+ |
refute rendered =~ "/private/checkout"
|
|
222
|
+ |
refute Map.has_key?(job, :goal)
|
|
223
|
+ |
refute Map.has_key?(job, :delegation)
|
|
224
|
+ |
refute Map.has_key?(job, :authority_snapshot)
|
|
225
|
+ |
refute Map.has_key?(job, :budget_snapshot)
|
|
226
|
+ |
end
|
|
227
|
+ |
end
|
|
228
|
+ |
|
|
229
|
+ |
test "an attempt with no job carries no job", context do
|
|
230
|
+ |
attempt(context, %{})
|
|
231
|
+ |
|
|
232
|
+ |
assert [%{work_job: nil}] = read_attempts(context, context.owner)
|
|
233
|
+ |
end
|
|
234
|
+ |
end
|
|
235
|
+ |
|
|
236
|
+ |
describe "an evidence edge on a public repository" do
|
|
237
|
+ |
test "says restricted evidence exists without naming the revision, receipt, or place",
|
|
238
|
+ |
context do
|
|
239
|
+ |
evidence(context, %{
|
|
240
|
+ |
family: "deployment",
|
|
241
|
+ |
plane: "tenant",
|
|
242
|
+ |
environment: "acme-production",
|
|
243
|
+ |
result: "succeeded"
|
|
244
|
+ |
})
|
|
245
|
+ |
|
|
246
|
+ |
assert [projection] = read_evidence(context, nil)
|
|
247
|
+ |
|
|
248
|
+ |
assert Enum.sort(Map.keys(projection)) ==
|
|
249
|
+ |
Enum.sort(WorkDisclosure.fields_at(:evidence, :pulse))
|
|
250
|
+ |
|
|
251
|
+ |
assert projection.family == "deployment"
|
|
252
|
+ |
assert projection.result == "succeeded"
|
|
253
|
+ |
assert projection.plane == "tenant"
|
|
254
|
+ |
|
|
255
|
+ |
refute Map.has_key?(projection, :commit)
|
|
256
|
+ |
refute Map.has_key?(projection, :receipt_id)
|
|
257
|
+ |
refute Map.has_key?(projection, :environment)
|
|
258
|
+ |
end
|
|
259
|
+ |
|
|
260
|
+ |
test "gives a member the revision, the receipt handle, and the environment", context do
|
|
261
|
+ |
evidence(context, %{
|
|
262
|
+ |
family: "deployment",
|
|
263
|
+ |
plane: "tenant",
|
|
264
|
+ |
environment: "acme-production",
|
|
265
|
+ |
result: "succeeded"
|
|
266
|
+ |
})
|
|
267
|
+ |
|
|
268
|
+ |
assert [projection] = read_evidence(context, context.member)
|
|
269
|
+ |
|
|
270
|
+ |
assert Enum.sort(Map.keys(projection)) ==
|
|
271
|
+ |
Enum.sort(WorkDisclosure.fields_at(:evidence, :ledger))
|
|
272
|
+ |
|
|
273
|
+ |
assert projection.commit == @sha
|
|
274
|
+ |
assert projection.environment == "acme-production"
|
|
275
|
+ |
end
|
|
276
|
+ |
|
|
277
|
+ |
test "never publishes the actor at any rung", context do
|
|
278
|
+ |
evidence(context, %{actor: "user:0e1c1a1e-secret"})
|
|
279
|
+ |
|
|
280
|
+ |
for reader <- [nil, context.stranger, context.member, context.owner] do
|
|
281
|
+ |
assert [projection] = read_evidence(context, reader)
|
|
282
|
+ |
refute Map.has_key?(projection, :actor)
|
|
283
|
+ |
refute inspect(projection) =~ "0e1c1a1e-secret"
|
|
284
|
+ |
end
|
|
285
|
+ |
end
|
|
286
|
+ |
|
|
287
|
+ |
test "inherits the attempt's link, so revoking the attempt takes its receipts",
|
|
288
|
+ |
context do
|
|
289
|
+ |
%{assignment: assignment} = attempt(context, %{terminal_commit: @sha})
|
|
290
|
+ |
|
|
291
|
+ |
# The edge is written by the production path, not fabricated here.
|
|
292
|
+ |
# `bind_attempt/1` sweeps the receipt chain for the revision the attempt
|
|
293
|
+ |
# reported and records what it finds, which is the only route by which an
|
|
294
|
+ |
# edge carries an `assignment_id` at all.
|
|
295
|
+ |
build_receipt(context, @sha, "complete")
|
|
296
|
+ |
|
|
297
|
+ |
assert [edge] = Evidence.bind_attempt(assignment)
|
|
298
|
+ |
assert edge.assignment_id == assignment.id
|
|
299
|
+ |
assert edge.artifact_link_id == assignment.artifact_link_id
|
|
300
|
+ |
|
|
301
|
+ |
assert [_projection] = read_evidence(context, context.member)
|
|
302
|
+ |
|
|
303
|
+ |
revoke(assignment.artifact_link_id)
|
|
304
|
+ |
|
|
305
|
+ |
assert read_evidence(context, context.member) == []
|
|
306
|
+ |
assert read_attempts(context, context.member) == []
|
|
307
|
+ |
end
|
|
308
|
+ |
end
|
|
309
|
+ |
|
|
310
|
+ |
# ── revocation ──────────────────────────────────────────────────────────
|
|
311
|
+ |
|
|
312
|
+ |
describe "revoking an attempt's link" do
|
|
313
|
+ |
test "removes the attempt from every reader, including its own account", context do
|
|
314
|
+ |
%{assignment: assignment} = attempt(context, %{}, job: true)
|
|
315
|
+ |
|
|
316
|
+ |
assert [_] = read_attempts(context, context.owner)
|
|
317
|
+ |
|
|
318
|
+ |
revoke(assignment.artifact_link_id)
|
|
319
|
+ |
|
|
320
|
+ |
for reader <- [nil, context.stranger, context.member, context.owner] do
|
|
321
|
+ |
assert read_attempts(context, reader) == []
|
|
322
|
+ |
end
|
|
323
|
+ |
end
|
|
324
|
+ |
|
|
325
|
+ |
test "leaves an auditable tombstone on the link", context do
|
|
326
|
+ |
%{assignment: assignment} = attempt(context, %{})
|
|
327
|
+ |
|
|
328
|
+ |
revoke(assignment.artifact_link_id)
|
|
329
|
+ |
|
|
330
|
+ |
link = Repo.get!(ArtifactLink, assignment.artifact_link_id)
|
|
331
|
+ |
|
|
332
|
+ |
assert link.revoked_at
|
|
333
|
+ |
assert link.revocation_tombstone["reason"] == "owner_withdrew_consent"
|
|
334
|
+ |
assert link.revocation_tombstone["revoked_at"]
|
|
335
|
+ |
assert Transparency.effective_tier(link, %{tier: :glass, admin: true}) == :dark
|
|
336
|
+ |
end
|
|
337
|
+ |
end
|
|
338
|
+ |
|
|
339
|
+ |
# ── repository authority is stronger than any tier ──────────────────────
|
|
340
|
+ |
|
|
341
|
+ |
describe "repository authority" do
|
|
342
|
+ |
test "a glass record in a repository that went private is invisible to a non-member",
|
|
343
|
+ |
context do
|
|
344
|
+ |
%{assignment: assignment} = attempt(context, %{terminal_commit: @sha}, job: true)
|
|
345
|
+ |
|
|
346
|
+ |
# The strongest possible tier: the link and the row both say `glass`, so
|
|
347
|
+ |
# nothing in the disclosure ladder is withholding anything.
|
|
348
|
+ |
Repo.update_all(from(l in ArtifactLink, where: l.id == ^assignment.artifact_link_id),
|
|
349
|
+ |
set: [tier: "glass"]
|
|
350
|
+ |
)
|
|
351
|
+ |
|
|
352
|
+ |
Repo.update_all(from(a in Assignment, where: a.id == ^assignment.id),
|
|
353
|
+ |
set: [transparency_tier: "glass"]
|
|
354
|
+ |
)
|
|
355
|
+ |
|
|
356
|
+ |
assert [_] = read_attempts(context, nil)
|
|
357
|
+ |
|
|
358
|
+ |
{1, _} =
|
|
359
|
+ |
Repo.update_all(
|
|
360
|
+ |
from(r in OpenAgents.Repositories.Repository, where: r.id == ^context.repository.id),
|
|
361
|
+ |
set: [visibility: "private"]
|
|
362
|
+ |
)
|
|
363
|
+ |
|
|
364
|
+ |
assert_raise Ecto.NoResultsError, fn ->
|
|
365
|
+ |
Repositories.get_visible_by_path!(
|
|
366
|
+ |
context.repository.owner,
|
|
367
|
+ |
context.repository.name,
|
|
368
|
+ |
nil
|
|
369
|
+ |
)
|
|
370
|
+ |
end
|
|
371
|
+ |
|
|
372
|
+ |
assert_raise Ecto.NoResultsError, fn ->
|
|
373
|
+ |
Repositories.get_visible_by_path!(
|
|
374
|
+ |
context.repository.owner,
|
|
375
|
+ |
context.repository.name,
|
|
376
|
+ |
context.stranger
|
|
377
|
+ |
)
|
|
378
|
+ |
end
|
|
379
|
+ |
end
|
|
380
|
+ |
end
|
|
381
|
+ |
|
|
382
|
+ |
# ── the viewer ladder ───────────────────────────────────────────────────
|
|
383
|
+ |
|
|
384
|
+ |
describe "the viewer descriptor" do
|
|
385
|
+ |
test "puts an operator at glass, a member at ledger, and everyone else at pulse",
|
|
386
|
+ |
context do
|
|
387
|
+ |
operator = admin_user()
|
|
388
|
+ |
|
|
389
|
+ |
assert WorkDisclosure.viewer(context.repository, operator).tier == :glass
|
|
390
|
+ |
assert WorkDisclosure.viewer(context.repository, context.member).tier == :ledger
|
|
391
|
+ |
assert WorkDisclosure.viewer(context.repository, context.owner).tier == :ledger
|
|
392
|
+ |
assert WorkDisclosure.viewer(context.repository, context.stranger).tier == :pulse
|
|
393
|
+ |
assert WorkDisclosure.viewer(context.repository, nil).tier == :pulse
|
|
394
|
+ |
assert WorkDisclosure.viewer(context.repository, nil).account_id == nil
|
|
395
|
+ |
end
|
|
396
|
+ |
|
|
397
|
+ |
test "an agent-requested attempt has no link, and only an operator reaches glass",
|
|
398
|
+ |
context do
|
|
399
|
+ |
attempt(context, %{}, job: true, link: false)
|
|
400
|
+ |
|
|
401
|
+ |
assert [%{work_job: operator_job}] = read_attempts(context, admin_user())
|
|
402
|
+ |
assert Map.has_key?(operator_job, :report)
|
|
403
|
+ |
|
|
404
|
+ |
# Nobody owns an attempt an agent requested, so nobody but an operator is
|
|
405
|
+ |
# raised. The account that would have owned a user-requested attempt
|
|
406
|
+ |
# reads it at `ledger` like any other member.
|
|
407
|
+ |
assert [%{work_job: owner_job}] = read_attempts(context, context.owner)
|
|
408
|
+ |
refute Map.has_key?(owner_job, :report)
|
|
409
|
+ |
|
|
410
|
+ |
assert [projection] = read_attempts(context, nil)
|
|
411
|
+ |
assert projection.requester_kind == "agent"
|
|
412
|
+ |
refute Map.has_key?(projection, :branch)
|
|
413
|
+ |
end
|
|
414
|
+ |
|
|
415
|
+ |
test "an operator reads an attempt at glass without owning it", context do
|
|
416
|
+ |
attempt(context, %{}, job: true)
|
|
417
|
+ |
|
|
418
|
+ |
assert [%{work_job: job}] = read_attempts(context, admin_user())
|
|
419
|
+ |
assert Map.has_key?(job, :report)
|
|
420
|
+ |
end
|
|
421
|
+ |
end
|
|
422
|
+ |
|
|
423
|
+ |
# ── the attempt link is minted where the attempt is ─────────────────────
|
|
424
|
+ |
|
|
425
|
+ |
describe "the attempt link" do
|
|
426
|
+ |
test "is minted for a user-requested attempt", context do
|
|
427
|
+ |
assert {:ok, link} =
|
|
428
|
+ |
WorkDisclosure.link_for_attempt(
|
|
429
|
+ |
context.repository,
|
|
430
|
+ |
%{"type" => "user", "id" => context.owner.id},
|
|
431
|
+ |
%{"branch" => "agent/issue-1"}
|
|
432
|
+ |
)
|
|
433
|
+ |
|
|
434
|
+ |
assert link.artifact_type == "attempt"
|
|
435
|
+ |
assert link.artifact_ref == "id"
|
|
436
|
+ |
assert link.tier == "ledger"
|
|
437
|
+ |
assert link.account_id == context.owner.id
|
|
438
|
+ |
end
|
|
439
|
+ |
|
|
440
|
+ |
test "is not minted for an agent-requested attempt", context do
|
|
441
|
+ |
assert :none ==
|
|
442
|
+ |
WorkDisclosure.link_for_attempt(
|
|
443
|
+ |
context.repository,
|
|
444
|
+ |
%{"type" => "agent", "id" => Ecto.UUID.generate()},
|
|
445
|
+ |
%{}
|
|
446
|
+ |
)
|
|
447
|
+ |
end
|
|
448
|
+ |
|
|
449
|
+ |
test "is what `Assignments.create/1` stores on the attempt it persists", context do
|
|
450
|
+ |
{:ok, conversation} = OpenAgents.Conversations.ensure_conversation(context.owner)
|
|
451
|
+ |
|
|
452
|
+ |
{:ok, box} =
|
|
453
|
+ |
%OpenAgents.Box.ConversationBox{}
|
|
454
|
+ |
|> OpenAgents.Box.ConversationBox.changeset(%{
|
|
455
|
+ |
conversation_id: conversation.id,
|
|
456
|
+ |
box_id: "bx_work_disclosure",
|
|
457
|
+ |
state: "ready",
|
|
458
|
+ |
setup_status: "done"
|
|
459
|
+ |
})
|
|
460
|
+ |
|> Repo.insert()
|
|
461
|
+ |
|
|
462
|
+ |
# The run never starts in a test, and it does not need to: the assignment
|
|
463
|
+ |
# and its link are committed by `persist_assignment/7` before
|
|
464
|
+ |
# `start_target/7` is reached, so the row this reads is the row the
|
|
465
|
+ |
# production path writes.
|
|
466
|
+ |
result =
|
|
467
|
+ |
try do
|
|
468
|
+ |
Assignments.create(%{
|
|
469
|
+ |
"target_kind" => "box",
|
|
470
|
+ |
"box_id" => box.box_id,
|
|
471
|
+ |
"conversation_id" => conversation.id,
|
|
472
|
+ |
"repository_id" => context.repository.id,
|
|
473
|
+ |
"issue_number" => context.issue.number,
|
|
474
|
+ |
"branch" => "agent/created",
|
|
475
|
+ |
"requesting_user" => context.owner,
|
|
476
|
+ |
"requesting_principal" => context.owner
|
|
477
|
+ |
})
|
|
478
|
+ |
rescue
|
|
479
|
+ |
# The box never starts here, and `start_target/7` records that as a
|
|
480
|
+ |
# terminal failure. Whatever it returns, the assignment and its link
|
|
481
|
+ |
# were committed by `persist_assignment/7` first, which is the row
|
|
482
|
+ |
# this test is about.
|
|
483
|
+ |
error -> {:error, error}
|
|
484
|
+ |
end
|
|
485
|
+ |
|
|
486
|
+ |
assignment =
|
|
487
|
+ |
case Repo.one(from a in Assignment, where: a.issue_id == ^context.issue.id, limit: 1) do
|
|
488
|
+ |
nil -> flunk("no assignment persisted: #{inspect(result)}")
|
|
489
|
+ |
row -> row
|
|
490
|
+ |
end
|
|
491
|
+ |
|
|
492
|
+ |
assert assignment.artifact_link_id
|
|
493
|
+ |
assert assignment.transparency_tier == "ledger"
|
|
494
|
+ |
|
|
495
|
+ |
link = Repo.get!(ArtifactLink, assignment.artifact_link_id)
|
|
496
|
+ |
assert link.account_id == context.owner.id
|
|
497
|
+ |
assert link.artifact_type == "attempt"
|
|
498
|
+ |
end
|
|
499
|
+ |
end
|
|
500
|
+ |
|
|
501
|
+ |
# ── the same viewer, the same answer, on every surface ──────────────────
|
|
502
|
+ |
|
|
503
|
+ |
describe "one schedule, read from one place" do
|
|
504
|
+ |
test "every caller of a work projection outside its own context passes a viewer" do
|
|
505
|
+ |
offenders =
|
|
506
|
+ |
"lib/**/*.ex"
|
|
507
|
+ |
|> Path.wildcard()
|
|
508
|
+ |
|> Enum.reject(
|
|
509
|
+ |
&(&1 in [
|
|
510
|
+ |
"lib/openagents/forge/assignments.ex",
|
|
511
|
+ |
"lib/openagents/issues/evidence.ex"
|
|
512
|
+ |
])
|
|
513
|
+ |
)
|
|
514
|
+ |
|> Enum.flat_map(fn path ->
|
|
515
|
+ |
path
|
|
516
|
+ |
|> File.read!()
|
|
517
|
+ |
|> then(
|
|
518
|
+ |
&Regex.scan(
|
|
519
|
+ |
~r/(?:Assignments\.attempts_for_issues?|Evidence\.for_issues?|attempt_summary|Evidence\.summary)\(([\s\S]{0,260})/,
|
|
520
|
+ |
&1
|
|
521
|
+ |
)
|
|
522
|
+ |
)
|
|
523
|
+ |
|> Enum.reject(fn [_whole, args] -> String.contains?(args, "viewer") end)
|
|
524
|
+ |
|> Enum.map(fn [whole, _args] -> "#{path}: #{String.slice(whole, 0, 60)}" end)
|
|
525
|
+ |
end)
|
|
526
|
+ |
|
|
527
|
+ |
assert offenders == [],
|
|
528
|
+ |
"a work projection read without a viewer runs at the unclamped default: " <>
|
|
529
|
+ |
Enum.join(offenders, "\n")
|
|
530
|
+ |
end
|
|
531
|
+ |
end
|
|
532
|
+ |
|
|
533
|
+ |
# ── helpers ─────────────────────────────────────────────────────────────
|
|
534
|
+ |
|
|
535
|
+ |
defp read_attempts(context, reader),
|
|
536
|
+ |
do: Assignments.attempts_for_issue(context.issue, viewer(context, reader))
|
|
537
|
+ |
|
|
538
|
+ |
defp read_evidence(context, reader),
|
|
539
|
+ |
do: Evidence.for_issue(context.issue, viewer(context, reader))
|
|
540
|
+ |
|
|
541
|
+ |
defp viewer(context, reader), do: WorkDisclosure.viewer(context.repository, reader)
|
|
542
|
+ |
|
|
543
|
+ |
defp attempt(context, attrs, opts \\ []) do
|
|
544
|
+ |
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
|
|
545
|
+ |
|
|
546
|
+ |
principal =
|
|
547
|
+ |
if Keyword.get(opts, :link, true) do
|
|
548
|
+ |
%{
|
|
549
|
+ |
"type" => "user",
|
|
550
|
+ |
"id" => context.owner.id,
|
|
551
|
+ |
"actor_type" => "user",
|
|
552
|
+ |
"actor_id" => context.owner.id
|
|
553
|
+ |
}
|
|
554
|
+ |
else
|
|
555
|
+ |
agent = Ecto.UUID.generate()
|
|
556
|
+ |
%{"type" => "agent", "id" => agent, "actor_type" => "agent", "actor_id" => agent}
|
|
557
|
+ |
end
|
|
558
|
+ |
|
|
559
|
+ |
link =
|
|
560
|
+ |
case WorkDisclosure.link_for_attempt(context.repository, principal, %{
|
|
561
|
+ |
"branch" => "agent/issue-1"
|
|
562
|
+ |
}) do
|
|
563
|
+ |
{:ok, link} -> link
|
|
564
|
+ |
:none -> nil
|
|
565
|
+ |
end
|
|
566
|
+ |
|
|
567
|
+ |
job = if opts[:job], do: work_job(context), else: nil
|
|
568
|
+ |
|
|
569
|
+ |
assignment =
|
|
570
|
+ |
%Assignment{}
|
|
571
|
+ |
|> Assignment.changeset(
|
|
572
|
+ |
Map.merge(
|
|
573
|
+ |
%{
|
|
574
|
+ |
conversation_box_id: box(context).id,
|
|
575
|
+ |
target_kind: "box",
|
|
576
|
+ |
repository_id: context.repository.id,
|
|
577
|
+ |
issue_id: context.issue.id,
|
|
578
|
+ |
requesting_principal: principal,
|
|
579
|
+ |
branch: "agent/issue-1",
|
|
580
|
+ |
deadline_at: DateTime.add(now, 600, :second),
|
|
581
|
+ |
admitted_at: now,
|
|
582
|
+ |
started_at: now,
|
|
583
|
+ |
finished_at: now,
|
|
584
|
+ |
work_job_id: job && job.id,
|
|
585
|
+ |
artifact_link_id: link && link.id
|
|
586
|
+ |
},
|
|
587
|
+ |
attrs
|
|
588
|
+ |
)
|
|
589
|
+ |
)
|
|
590
|
+ |
|> Repo.insert!()
|
|
591
|
+ |
|
|
592
|
+ |
%{assignment: assignment, link: link, job: job}
|
|
593
|
+ |
end
|
|
594
|
+ |
|
|
595
|
+ |
defp box(context) do
|
|
596
|
+ |
{:ok, conversation} = OpenAgents.Conversations.ensure_conversation(context.owner)
|
|
597
|
+ |
|
|
598
|
+ |
{:ok, box} =
|
|
599
|
+ |
%OpenAgents.Box.ConversationBox{}
|
|
600
|
+ |
|> OpenAgents.Box.ConversationBox.changeset(%{
|
|
601
|
+ |
conversation_id: conversation.id,
|
|
602
|
+ |
box_id: "bx_wd_#{System.unique_integer([:positive])}",
|
|
603
|
+ |
state: "ready",
|
|
604
|
+ |
setup_status: "done"
|
|
605
|
+ |
})
|
|
606
|
+ |
|> Repo.insert()
|
|
607
|
+ |
|
|
608
|
+ |
box
|
|
609
|
+ |
end
|
|
610
|
+ |
|
|
611
|
+ |
defp work_job(context) do
|
|
612
|
+ |
{:ok, conversation} = OpenAgents.Conversations.ensure_conversation(context.owner)
|
|
613
|
+ |
visitor = Repo.get_by!(OpenAgents.Conversations.Visitor, user_id: context.owner.id)
|
|
614
|
+ |
|
|
615
|
+ |
%Job{}
|
|
616
|
+ |
|> Job.create_changeset(%{
|
|
617
|
+ |
conversation_id: conversation.id,
|
|
618
|
+ |
owner_visitor_id: visitor.id,
|
|
619
|
+ |
surface: "text",
|
|
620
|
+ |
kind: "coding",
|
|
621
|
+ |
goal: "SECRET-GOAL rename the billing column",
|
|
622
|
+ |
context_hint: "SECRET-PROMPT the private schema",
|
|
623
|
+ |
authority_snapshot: %{"roots" => ["/private/checkout"], "cwd" => "/private/checkout"},
|
|
624
|
+ |
budget_snapshot: %{"wall_clock_ms" => 60_000, "maximum_report_bytes" => 8_000}
|
|
625
|
+ |
})
|
|
626
|
+ |
|> Repo.insert!()
|
|
627
|
+ |
|> Job.lifecycle_changeset(%{status: "running", started_at: DateTime.utc_now()})
|
|
628
|
+ |
|> Repo.update!()
|
|
629
|
+ |
|> Job.lifecycle_changeset(%{
|
|
630
|
+ |
status: "completed",
|
|
631
|
+ |
report: "Renamed the billing column and pushed.",
|
|
632
|
+ |
model_id: "test-model",
|
|
633
|
+ |
tool_call_count: 7,
|
|
634
|
+ |
continuation_count: 1,
|
|
635
|
+ |
usage: %{"input_tokens" => 100},
|
|
636
|
+ |
started_at: DateTime.utc_now(),
|
|
637
|
+ |
completed_at: DateTime.utc_now()
|
|
638
|
+ |
})
|
|
639
|
+ |
|> Repo.update!()
|
|
640
|
+ |
end
|
|
641
|
+ |
|
|
642
|
+ |
defp evidence(context, attrs) do
|
|
643
|
+ |
defaults = %{
|
|
644
|
+ |
repository_id: context.repository.id,
|
|
645
|
+ |
issue_id: context.issue.id,
|
|
646
|
+ |
commit_sha: @sha,
|
|
647
|
+ |
family: "build",
|
|
648
|
+ |
receipt_id: Ecto.UUID.generate(),
|
|
649
|
+ |
plane: "forge",
|
|
650
|
+ |
result: "complete",
|
|
651
|
+ |
actor: "user:someone",
|
|
652
|
+ |
source: "closing_reference"
|
|
653
|
+ |
}
|
|
654
|
+ |
|
|
655
|
+ |
attrs = Map.merge(defaults, attrs)
|
|
656
|
+ |
|
|
657
|
+ |
%EvidenceEntry{}
|
|
658
|
+ |
|> EvidenceEntry.changeset(attrs)
|
|
659
|
+ |
|> Repo.insert!()
|
|
660
|
+ |
end
|
|
661
|
+ |
|
|
662
|
+ |
defp build_receipt(context, sha, status) do
|
|
663
|
+ |
%OpenAgents.Forge.BuildReceipt{}
|
|
664
|
+ |
|> OpenAgents.Forge.BuildReceipt.start_changeset(%{
|
|
665
|
+ |
repo: context.repository.storage_key,
|
|
666
|
+ |
repository_id: context.repository.id,
|
|
667
|
+ |
sha: sha,
|
|
668
|
+ |
target_id: Ecto.UUID.generate()
|
|
669
|
+ |
})
|
|
670
|
+ |
|> Ecto.Changeset.put_change(:status, status)
|
|
671
|
+ |
|> Repo.insert!()
|
|
672
|
+ |
end
|
|
673
|
+ |
|
|
674
|
+ |
defp revoke(link_id) do
|
|
675
|
+ |
ArtifactLink
|
|
676
|
+ |
|> Repo.get!(link_id)
|
|
677
|
+ |
|> Transparency.revoke("owner_withdrew_consent", Ecto.UUID.generate())
|
|
678
|
+ |
|> Repo.update!()
|
|
679
|
+ |
end
|
|
680
|
+ |
|
|
681
|
+ |
# The owner's GitHub id is unioned into the operator list in
|
|
682
|
+ |
# `OpenAgents.Accounts` itself rather than read from configuration, so this
|
|
683
|
+ |
# needs no global state and stays safe to run concurrently.
|
|
684
|
+ |
defp admin_user do
|
|
685
|
+ |
{:ok, user} =
|
|
686
|
+ |
OpenAgents.Accounts.upsert_github_user(%{
|
|
687
|
+ |
github_id: 14_167_547,
|
|
688
|
+ |
github_login: "work-operator",
|
|
689
|
+ |
github_avatar_url: "https://avatars.githubusercontent.com/u/14167547?v=4"
|
|
690
|
+ |
})
|
|
691
|
+ |
|
|
692
|
+ |
true = OpenAgents.Accounts.admin?(user)
|
|
693
|
+ |
user
|
|
694
|
+ |
end
|
|
695
|
+ |
end
|