Let the list show which issues are actually being worked

deda77c320bd · AtlantisPleb · · parent 4eb5b1cb2f2e

Let the list show which issues are actually being worked

The started arc has shipped in the component set since Circle landed and never
had data behind it. `IssuePresentation.category/2` already returned `:started`
for an open issue whose progress was `in_progress`; what was missing was
anything producing that value. `Issues.progress_map/2` derived it from one
input — a project board column — and nothing in how work begins here touches a
board. An agent picks an issue up, works it, pushes, and closes it, and no
column ever moves, so 10 of 12 open issues read `to_do` while nine agents ran.

Progress now derives from three records instead of one, and both new inputs are
records some other act already wrote rather than a field anybody maintains:

  * An attempt holding the issue. `forge_assignments` is the one record binding
    an issue to work, and its claim is already singular under a partial unique
    index over `admitted` and `running`.
  * A session bound to the issue. An agent is launched with a prompt naming the
    issue it is to work, and that prompt is the thread's objective, so
    `Threads.open/3` resolves the reference once — through
    `Forge.CommitReferences`, the one place a `#N` reference is defined here —
    and stores it in the `threads.issue_id` column the schema already had and
    no caller ever set. The binding needs no second act and no CLI change.
  * A board column, unchanged, now one input of three.

Each input says when it stops counting, so a claim cannot read as work forever.
An attempt counts while its deadline holds, which `Forge.AssignmentExpiry`
already reaps and the query reads too. A thread counts while it is open and has
recorded something in the last two hours: every appended event advances
`updated_at`, so that column is the session's heartbeat, and a session that
records a turn and every tool it runs does not fall silent that long while
working. The thread stays open — it holds its admission slot until it finishes
or its authority is spent — but it stops claiming an issue. A board column is a
deliberate statement and keeps no clock.

Visibility is preserved input by input, each through the authority its own
record answers to: `Threads.readable_by/2` for a session (THREAD-002),
`Repositories.readable_by/2` for a board, and for an attempt the two ways
`WorkDisclosure` resolves to `dark` — its own tier and a revoked artifact link.
An owner-only session, a private board, and a withheld attempt all leave the
issue reading `to_do` for a reader who cannot see them. Nothing published says
who is working, only that work is under way.

The derived field and the `?progress=` filter now compose one `started_dynamic/1`
expression against the same `:issue` binding, so the API and the list still
cannot disagree about the same issue.

Closes #254
Closes
#254

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 407 · 2026-08-25T15:39:51.869602Z

Changed files

  • modified INVARIANTS.md
  • modified lib/openagents/issues.ex
  • modified lib/openagents/threads.ex
  • modified lib/openagents_web/components/issue_presentation.ex
  • modified lib/openagents_web/controllers/api_extension_controller.ex
  • modified priv/docs/rest-api.md
  • modified test/openagents/issue_progress_test.exs
  • modified test/openagents/threads/grant_token_reach_test.exs
  • modified test/openagents_web/live/issue_index_live_test.exs

Diff

9 files changed, +621 -56

INVARIANTS.md modified +13 -6

@@ -4244,12 +4244,19 @@ for, or a published enum that has drifted from the value the context derives.

4244 4244
A governance rule nothing enforces would be a contract with no proof.
4245 4245
4246 4246
Derived fields state their sources, including whose visibility.
4247
`issue.openagents.progress` is derived from the reader's own readable boards
4248
through `OpenAgents.Repositories.readable_by/2`, the one predicate every
4249
repository surface composes, so a column on a board in a private repository the
4250
reader cannot open never becomes a fact about a public issue. The filter and
4251
the field read the same query, so a listed issue always reports the value it
4252
was listed under.
4247
`issue.openagents.progress` is derived from three records the reader may
4248
already read, each through the authority that record answers to: an attempt
4249
holding the issue in `forge_assignments`, admitted at `pulse` by
4250
`OpenAgents.Transparency.WorkDisclosure` and withheld when its own tier is
4251
`dark` or its artifact link is revoked; a thread bound to the issue, narrowed
4252
by `OpenAgents.Threads.readable_by/2` (THREAD-002); and a board column, narrowed
4253
by `OpenAgents.Repositories.readable_by/2`, the one predicate every repository
4254
surface composes. So neither a column on a board in a private repository, nor
4255
an owner-only session, nor a withheld attempt ever becomes a fact about a
4256
public issue. Each input also says when it stops counting — a passed deadline,
4257
a quiet session, a column somebody moves — so a claim cannot read as work
4258
forever. The filter and the field compose the same expression, so a listed
4259
issue always reports the value it was listed under.
4253 4260
4254 4261
Evidence: `OpenAgentsWeb.ApiExtensionController`, `OpenAgentsWeb.IssueJSON`,
4255 4262
`OpenAgents.Issues`,
lib/openagents/issues.ex modified +152 -25

@@ -16,6 +16,8 @@ defmodule OpenAgents.Issues do

16 16
  alias OpenAgents.Accounts.User
17 17
  alias OpenAgents.Agents.Agent
18 18
  alias OpenAgents.Analytics
19
  alias OpenAgents.Conversations.Visitor
20
  alias OpenAgents.Forge.Assignment
19 21
  alias OpenAgents.Issues.{Comment, Issue, IssueDependency, TaskReferences, UnknownReference}
20 22
  alias OpenAgents.Labels
21 23
  alias OpenAgents.Labels.Label

@@ -26,6 +28,9 @@ defmodule OpenAgents.Issues do

26 28
  alias OpenAgents.Repo
27 29
  alias OpenAgents.Repositories
28 30
  alias OpenAgents.Repositories.Repository
31
  alias OpenAgents.Threads
32
  alias OpenAgents.Threads.Thread
33
  alias OpenAgents.Transparency.ArtifactLink
29 34
30 35
  @issues_per_page 25
31 36
  @maximum_page 10_000

@@ -45,6 +50,15 @@ defmodule OpenAgents.Issues do

45 50
    "started"
46 51
  ]
47 52
53
  # The attempt states that hold an issue's claim. `forge_assignments` carries a
54
  # partial unique index over exactly these two, so at most one attempt is
55
  # working an issue at a time, and reaching a terminal state releases it.
56
  @claiming_attempt_states ~w(admitted running)
57
58
  # How long an open thread bound to an issue keeps reading as work in flight
59
  # after its last recorded event. See the going-quiet rule in `progress_map/2`.
60
  @thread_quiet_after_seconds 2 * 60 * 60
61
48 62
  @doc "How many issues one index page shows."
49 63
  def per_page, do: @issues_per_page
50 64

@@ -61,8 +75,8 @@ defmodule OpenAgents.Issues do

61 75
  Supported options: `:type`, `:state`, `:label`, `:assignee`, `:milestone`,
62 76
  `:q`, `:blocked`, `:progress`, `:reader`, and `:page`. Filters compose;
63 77
  counts and pages always agree because they read the same query. `:reader` is
64
  the user whose readable boards `:progress` derives from, and only that option
65
  reads it.
78
  the user whose readable attempts, sessions, and boards `:progress` derives
79
  from, and only that option reads it.
66 80
67 81
  `:type` defaults to `"issue"`, which excludes the issue rows pull requests
68 82
  are built on. Pass `"pull_request"` for only those, or `"all"` for GitHub's

@@ -278,6 +292,17 @@ defmodule OpenAgents.Issues do

278 292
  def get_issue_by_number!(%Repository{id: repository_id}, number) when is_integer(number),
279 293
    do: Repo.get_by!(Issue, repository_id: repository_id, number: number)
280 294
295
  @doc """
296
  The issue numbered `number` in `repository`, or `nil`.
297
298
  The same lookup as `get_issue_by_number!/2` for a caller resolving a
299
  reference somebody typed, where a number naming no issue is an ordinary
300
  answer rather than an exception.
301
  """
302
  @spec get_issue_by_number(Repository.t(), integer()) :: Issue.t() | nil
303
  def get_issue_by_number(%Repository{id: repository_id}, number) when is_integer(number),
304
    do: Repo.get_by(Issue, repository_id: repository_id, number: number)
305
281 306
  def get_issue_by_path!(owner, repository_name, number) when is_integer(number) do
282 307
    # The repository is resolved through the one read predicate rather than a
283 308
    # restated join. The copy this replaced omitted `lifecycle_state`, so an

@@ -503,18 +528,77 @@ defmodule OpenAgents.Issues do

503 528
  @doc """
504 529
  How far along each issue in `issues` is, keyed by issue id.
505 530
506
  Progress is derived, never stored. An issue is `"done"` when it is closed,
507
  because closing an issue is the act that finishes it. An open issue is
508
  `"in_progress"` when a board `reader` can read places it in a started column,
509
  and `"to_do"` otherwise — including when the only board saying otherwise is
510
  one the reader cannot open, so a private board's column never becomes a fact
511
  about a public issue.
531
  Progress is derived, never stored. Nothing here reads a column somebody had
532
  to remember to set: every input below is a record some other act already
533
  wrote, which is why the value can be trusted without anybody maintaining it.
534
535
  An issue is `"done"` when it is closed, because closing an issue is the act
536
  that finishes it. An open issue is `"in_progress"` when any one of three
537
  records the `reader` may read says work is under way on it, and `"to_do"`
538
  otherwise.
539
540
  ## The three ways work starts
541
542
    * **An attempt holds the issue.** `forge_assignments` is the one record
543
      binding an issue to work: `OpenAgents.Forge.Assignments.create/1` claims
544
      the issue, and a partial unique index over the claiming states keeps that
545
      claim singular. An attempt in `admitted` or `running` is work in flight.
546
    * **A session is bound to the issue.** A coding session opens a thread, and
547
      a thread whose objective named an issue in its own repository carries
548
      that issue's id (`OpenAgents.Threads.open/3`). An open thread that has
549
      recorded something recently is an agent working right now. This is the
550
      input that matters most here, because it is the one the workflow that
551
      produces almost all the movement actually writes.
552
    * **A board says so.** A project board the reader can open places the issue
553
      in a started column. This was the only input, and is now one of three,
554
      because nothing in how work begins here touches a board (issue #254).
512 555
513 556
  A `Done` column on an open issue reads `"to_do"`: the board and the issue
514 557
  disagree, and the issue's own state is the one both the UI and the API
515 558
  already treat as authoritative.
516 559
517
  One query serves a whole page, so rendering a list never walks the boards
560
  ## The going-quiet rule
561
562
  A claim that never lapses would say work is under way forever, so each input
563
  says when it stops counting, and each says it from a clock that record
564
  already keeps:
565
566
    * An **attempt** counts while it holds the claim and its `deadline_at` has
567
      not passed. `OpenAgents.Forge.AssignmentExpiry` reaps past-deadline
568
      attempts every minute, and the deadline is read here too, so a reader
569
      never sees a claim the reaper has not yet reached.
570
    * A **thread** counts while it is open and something was recorded on it
571
      within #{div(@thread_quiet_after_seconds, 3600)} hours. Every appended
572
      event advances the thread's `updated_at`, so that column is the session's
573
      heartbeat. A session records a turn and every tool it runs; silence that
574
      long is not a slow step, it is a session nobody came back to. The thread
575
      stays open — a thread holds its admission slot until it finishes or its
576
      authority is spent, and waiting alone reaches neither — but it stops
577
      claiming an issue.
578
    * A **board column** has no clock and is given none. It is a statement
579
      somebody made deliberately, and it stands until somebody moves it.
580
581
  ## Visibility
582
583
  Every input is read through the authority that record already answers to, so
584
  a private claim never becomes a public fact about an issue:
585
586
    * A **board item** is evidence only for a reader who could open the board it
587
      sits on, through the one readable predicate every repository surface
588
      composes.
589
    * A **thread** is evidence only for a reader who may read it, through
590
      `OpenAgents.Threads.readable_by/2` — its owner, or anybody when the
591
      thread was opened at a tier wider than owner-only (THREAD-002).
592
    * An **attempt** is evidence only when its transparency tier is not `dark`
593
      and its artifact link is not revoked. That an attempt of some shape is
594
      running is a `pulse` fact for any reader who reached the issue
595
      (`OpenAgents.Transparency.WorkDisclosure`), and the two conditions here
596
      are the two ways that projection resolves to `dark` instead.
597
598
  Nothing about *who* is working reaches this value. It answers "is work under
599
  way", never whose thread, whose board, or whose attempt.
600
601
  One query serves a whole page, so rendering a list never walks these records
518 602
  once per row.
519 603
  """
520 604
  def progress_map(issues, reader \\ nil)

@@ -541,14 +625,60 @@ defmodule OpenAgents.Issues do

541 625
  defp started_issue_ids([], _reader), do: MapSet.new()
542 626
543 627
  defp started_issue_ids(ids, reader) do
544
    reader
545
    |> started_item_query()
546
    |> where([item], item.issue_id in ^ids)
547
    |> select([item], item.issue_id)
628
    from(issue in Issue, as: :issue, where: issue.id in ^ids, select: issue.id)
629
    |> where(^started_dynamic(reader))
548 630
    |> Repo.all()
549 631
    |> MapSet.new()
550 632
  end
551 633
634
  # The one place "work has started" is defined. Both the derived field and the
635
  # `?progress=` filter compose this expression against a query that binds the
636
  # issue as `:issue`, so the value the API serves and the rows the filter
637
  # returns cannot disagree about the same issue.
638
  defp started_dynamic(reader) do
639
    dynamic(
640
      exists(claiming_attempt_query()) or
641
        exists(bound_thread_query(reader)) or
642
        exists(started_item_query(reader))
643
    )
644
  end
645
646
  # An attempt that holds the issue's claim and whose deadline has not passed,
647
  # withheld when its own projection resolves to `dark`.
648
  defp claiming_attempt_query do
649
    now = DateTime.utc_now()
650
651
    from(attempt in Assignment,
652
      left_join: link in ArtifactLink,
653
      on: link.id == attempt.artifact_link_id,
654
      where: attempt.issue_id == parent_as(:issue).id,
655
      where: attempt.state in ^@claiming_attempt_states,
656
      where: attempt.deadline_at > ^now,
657
      where: attempt.transparency_tier != "dark",
658
      where: is_nil(attempt.artifact_link_id) or is_nil(link.revoked_at),
659
      select: 1
660
    )
661
  end
662
663
  # An open thread bound to the issue that has not gone quiet, narrowed to what
664
  # the reader may read by the thread context's own predicate rather than a
665
  # restatement of it.
666
  defp bound_thread_query(reader) do
667
    quiet_before = DateTime.add(DateTime.utc_now(), -@thread_quiet_after_seconds, :second)
668
669
    from(thread in Thread,
670
      as: :thread,
671
      join: owner in Visitor,
672
      as: :owner,
673
      on: owner.id == thread.owner_visitor_id,
674
      where: thread.issue_id == parent_as(:issue).id,
675
      where: thread.status == "open",
676
      where: thread.updated_at > ^quiet_before,
677
      select: 1
678
    )
679
    |> Threads.readable_by(reader)
680
  end
681
552 682
  # A board item is only evidence for a reader who could open the board it sits
553 683
  # on, so the board repository passes through the one readable predicate every
554 684
  # repository surface composes.

@@ -557,9 +687,11 @@ defmodule OpenAgents.Issues do

557 687
      from(repository in Repositories.readable_by(Repository, reader), select: repository.id)
558 688
559 689
    from(item in ProjectItem,
690
      where: item.issue_id == parent_as(:issue).id,
560 691
      where: item.repository_id in subquery(readable),
561 692
      where:
562
        fragment("lower(btrim(coalesce(? ->> 'Status', '')))", item.values) in ^@started_columns
693
        fragment("lower(btrim(coalesce(? ->> 'Status', '')))", item.values) in ^@started_columns,
694
      select: 1
563 695
    )
564 696
  end
565 697

@@ -1268,8 +1400,8 @@ defmodule OpenAgents.Issues do

1268 1400
1269 1401
  defp maybe_filter_progress(query, nil, _reader), do: query
1270 1402
1271
  # The filter reads the same closed-issue rule and the same started-column
1272
  # query the derived field does, so `?progress=` and `issue.openagents.progress`
1403
  # The filter reads the same closed-issue rule and the same started expression
1404
  # the derived field does, so `?progress=` and `issue.openagents.progress`
1273 1405
  # cannot disagree about the same issue.
1274 1406
  defp maybe_filter_progress(query, "done", _reader),
1275 1407
    do: where(query, [issue], issue.state == "closed")

@@ -1277,20 +1409,15 @@ defmodule OpenAgents.Issues do

1277 1409
  defp maybe_filter_progress(query, "in_progress", reader) do
1278 1410
    query
1279 1411
    |> where([issue], issue.state == "open")
1280
    |> where([], exists(started_exists_query(reader)))
1412
    |> where(^started_dynamic(reader))
1281 1413
  end
1282 1414
1283 1415
  defp maybe_filter_progress(query, "to_do", reader) do
1416
    started = started_dynamic(reader)
1417
1284 1418
    query
1285 1419
    |> where([issue], issue.state == "open")
1286
    |> where([], not exists(started_exists_query(reader)))
1287
  end
1288
1289
  defp started_exists_query(reader) do
1290
    reader
1291
    |> started_item_query()
1292
    |> where([item], item.issue_id == parent_as(:issue).id)
1293
    |> select([], 1)
1420
    |> where(^dynamic(not (^started)))
1294 1421
  end
1295 1422
1296 1423
  defp maybe_filter_search(query, nil), do: query
lib/openagents/threads.ex modified +119 -15

@@ -74,10 +74,14 @@ defmodule OpenAgents.Threads do

74 74
  alias OpenAgents.Chat.OpenRouter
75 75
  alias OpenAgents.Conversations
76 76
  alias OpenAgents.Conversations.Visitor
77
  alias OpenAgents.Forge.CommitReferences
77 78
  alias OpenAgents.Inference
78 79
  alias OpenAgents.Inference.{Credit, Grant, Models, Pricing}
80
  alias OpenAgents.Issues
79 81
  alias OpenAgents.Issues.Issue
80 82
  alias OpenAgents.Repo
83
  alias OpenAgents.Repositories
84
  alias OpenAgents.Repositories.Repository
81 85
  alias OpenAgents.Threads.Event
82 86
  alias OpenAgents.Threads.Thread
83 87

@@ -100,6 +104,14 @@ defmodule OpenAgents.Threads do

100 104
  an account already at `maximum_open_per_account/0` is refused with
101 105
  `:thread_quota_reached` rather than given a further grant.
102 106
107
  `:issue_id` binds the thread to the issue it is work for. A caller that names
108
  none gets one derived: when `:repository` is given and the objective names an
109
  issue in that repository the opener can read, the thread carries that issue's
110
  id. An agent is launched with a prompt that names the issue it is to work,
111
  and that prompt is the objective, so the binding needs no second act — which
112
  is what lets an issue read `In progress` while a session is on it
113
  (`OpenAgents.Issues.progress_map/2`, issue #254).
114
103 115
  `:visibility` is the thread's transparency tier and defaults to
104 116
  `OpenAgents.Threads.Thread.default_visibility/0`, owner-only. A wider tier
105 117
  given here is recorded in the transcript as `thread.visibility_set`

@@ -123,9 +135,72 @@ defmodule OpenAgents.Threads do

123 135
124 136
  def open(%Visitor{id: visitor_id} = owner, objective, options) when is_binary(objective) do
125 137
    _reaped = reap_expired(owner)
126
    insert_thread(visitor_id, objective, options)
138
    insert_thread(visitor_id, objective, bind_issue(owner, objective, options))
139
  end
140
141
  # An agent is launched with a prompt that names the issue it is to work, and
142
  # that prompt is the thread's objective. Resolving the reference once, here,
143
  # is what gives `threads.issue_id` a writer — and what lets an issue read
144
  # `In progress` while a session is on it without anybody marking a board or
145
  # keeping a second field by hand (issue #254).
146
  #
147
  # The reference is read by `OpenAgents.Forge.CommitReferences`, which is the
148
  # one place a `#N` reference is defined here, so an objective and a commit
149
  # message mean the same thing by `#254`. The boundaries are the ones the
150
  # closing-reference path already draws: the thread must name the repository
151
  # it runs in, only a bare `#N` or that same `owner/name#N` resolves, and the
152
  # issue must be one the opener could already read. A caller that passed an
153
  # explicit `:issue_id` has said which issue it means, and is left alone.
154
  defp bind_issue(%Visitor{} = owner, objective, options) do
155
    if Keyword.has_key?(options, :issue_id) do
156
      options
157
    else
158
      case referenced_issue(owner, objective, Keyword.get(options, :repository)) do
159
        %Issue{id: id} -> Keyword.put(options, :issue_id, id)
160
        nil -> options
161
      end
162
    end
163
  end
164
165
  defp referenced_issue(_owner, _objective, path) when not is_binary(path), do: nil
166
167
  defp referenced_issue(%Visitor{user_id: user_id}, objective, path) do
168
    with [owner, name] <- String.split(String.trim(path), "/", parts: 2),
169
         number when is_integer(number) <- referenced_number(objective, owner, name),
170
         %Repository{} = repository <-
171
           Repositories.visible_by_path(owner, name, reader(user_id)) do
172
      Issues.get_issue_by_number(repository, number)
173
    else
174
      _unresolved -> nil
175
    end
176
  end
177
178
  defp reader(nil), do: nil
179
  defp reader(user_id), do: Repo.get(User, user_id)
180
181
  # The first reference in the objective that names this thread's own
182
  # repository, bare or qualified. A reference to another repository resolves
183
  # to nothing, the same boundary `OpenAgents.Issues.ClosingReferences` draws:
184
  # binding across repositories asks a second authority question this does not
185
  # answer.
186
  defp referenced_number(objective, owner, name) do
187
    objective
188
    |> CommitReferences.all()
189
    |> Enum.find_value(fn reference ->
190
      if same_repository?(reference, owner, name), do: reference.number
191
    end)
127 192
  end
128 193
194
  defp same_repository?(%{owner: nil, repository: nil}, _owner, _name), do: true
195
196
  defp same_repository?(%{owner: owner, repository: name}, path_owner, path_name)
197
       when is_binary(owner) and is_binary(name) do
198
    String.downcase(owner) == String.downcase(path_owner) and
199
      String.downcase(name) == String.downcase(path_name)
200
  end
201
202
  defp same_repository?(_reference, _owner, _name), do: false
203
129 204
  @doc """
130 205
  Open a thread and mint its authority, or leave nothing behind.
131 206

@@ -342,13 +417,15 @@ defmodule OpenAgents.Threads do

342 417
    with {:ok, id} <- Ecto.UUID.cast(thread_id),
343 418
         {%Thread{} = thread, owner_user_id} <-
344 419
           Repo.one(
345
             from(t in Thread,
346
               join: v in Visitor,
347
               on: v.id == t.owner_visitor_id,
348
               where: t.id == ^id,
349
               select: {t, v.user_id}
420
             from(thread in Thread,
421
               as: :thread,
422
               join: owner in Visitor,
423
               as: :owner,
424
               on: owner.id == thread.owner_visitor_id,
425
               where: thread.id == ^id,
426
               select: {thread, owner.user_id}
350 427
             )
351
             |> readable_for(user)
428
             |> readable_by(user)
352 429
           ) do
353 430
      {:ok, thread, if(owner_user_id == user.id, do: :owner, else: :reader)}
354 431
    else

@@ -389,21 +466,48 @@ defmodule OpenAgents.Threads do

389 466
  """
390 467
  @spec list_for_issue(Issue.t(), User.t()) :: [Thread.t()]
391 468
  def list_for_issue(%Issue{id: issue_id}, %User{} = reader) do
392
    from(t in Thread,
393
      join: v in Visitor,
394
      on: v.id == t.owner_visitor_id,
395
      where: t.issue_id == ^issue_id,
396
      order_by: [desc: t.inserted_at, desc: t.id],
469
    from(thread in Thread,
470
      as: :thread,
471
      join: owner in Visitor,
472
      as: :owner,
473
      on: owner.id == thread.owner_visitor_id,
474
      where: thread.issue_id == ^issue_id,
475
      order_by: [desc: thread.inserted_at, desc: thread.id],
397 476
      limit: ^@maximum_listed
398 477
    )
399
    |> readable_for(reader)
478
    |> readable_by(reader)
400 479
    |> Repo.all()
401 480
  end
402 481
403
  defp readable_for(query, %User{id: user_id}) do
482
  @doc """
483
  Narrows a thread query to the threads `reader` may read.
484
485
  A thread is admitted when the reader owns it, or when it was opened at a tier
486
  wider than owner-only, because a thread's transcript is private until its
487
  owner says otherwise (THREAD-002). An anonymous reader owns nothing, so only
488
  the wide tiers reach them.
489
490
  This is public so a caller outside this context composes the thread's own
491
  authority rather than restating it — `OpenAgents.Issues.progress_map/2`
492
  derives "an agent is working this issue" from a bound thread and must not
493
  invent a second answer to who may see one. The query must bind the thread as
494
  `:thread` and its owner visitor as `:owner`.
495
  """
496
  @spec readable_by(Ecto.Queryable.t(), User.t() | nil) :: Ecto.Query.t()
497
  def readable_by(query, reader)
498
499
  def readable_by(query, %User{id: user_id}) do
404 500
    wide = Thread.wide_visibilities()
405 501
406
    where(query, [t, v], v.user_id == ^user_id or t.visibility in ^wide)
502
    where(
503
      query,
504
      [thread: thread, owner: owner],
505
      owner.user_id == ^user_id or thread.visibility in ^wide
506
    )
507
  end
508
509
  def readable_by(query, nil) do
510
    where(query, [thread: thread], thread.visibility in ^Thread.wide_visibilities())
407 511
  end
408 512
409 513
  defp in_repository(query, repository) when is_binary(repository),
lib/openagents_web/components/issue_presentation.ex modified +4 -3

@@ -67,9 +67,10 @@ defmodule OpenAgentsWeb.Components.IssuePresentation do

67 67
  reason with a distinct reading, so it keeps the cancelled glyph; every other
68 68
  close is the purple check-circle.
69 69
70
  The exception is `in_progress`. An open issue a board says someone has
71
  started takes Circle's `:started` arc, which is the shape the component set
72
  has always drawn and never had data for. The value is the same derived
70
  The exception is `in_progress`. An open issue somebody has started takes
71
  Circle's `:started` arc — an attempt holding the issue, a session bound to
72
  it, or a board column saying so, whichever the reader may see
73
  (`OpenAgents.Issues.progress_map/2`). The value is the same derived
73 74
  `issue.openagents.progress` the API serves, read through the same reader's
74 75
  visibility, so the list and the API cannot show different work as underway.
75 76
  A caller that has not read progress passes none and gets GitHub's two states.
lib/openagents_web/controllers/api_extension_controller.ex modified +3 -2

@@ -304,8 +304,9 @@ defmodule OpenAgentsWeb.ApiExtensionController do

304 304
          "enum" => OpenAgents.Issues.progress_values(),
305 305
          "description" =>
306 306
            "How far along the issue is. Derived: a closed issue is done, and " <>
307
              "an open issue is in_progress while a board the reader can open " <>
308
              "places it in a started column."
307
              "an open issue is in_progress while an attempt holds it, a " <>
308
              "session the reader can read is bound to it, or a board the " <>
309
              "reader can open places it in a started column."
309 310
        },
310 311
        "work" => %{
311 312
          "type" => "array",
priv/docs/rest-api.md modified +19 -5

@@ -161,14 +161,28 @@ Responses that carry an extension name the namespace in the

161 161
derived, never stored:
162 162
163 163
- A closed issue is `done`. Closing an issue is the act that finishes it.
164
- An open issue is `in_progress` while a project board the reader can open
165
  places it in a started column — `In Progress`, `In review`, or `Started`,
166
  matched without regard to case or separators.
164
- An open issue is `in_progress` while any one of three records says work is
165
  under way on it:
166
  - **An attempt holds it.** An execution attempt against the issue is
167
    `admitted` or `running` and its deadline has not passed.
168
  - **A session is bound to it.** A coding session the reader may read is open
169
    on the issue and has recorded something in the last two hours. A session
170
    binds itself: a thread whose objective names an issue in its own
171
    repository carries that issue.
172
  - **A board says so.** A project board the reader can open places the issue
173
    in a started column — `In Progress`, `In review`, or `Started`, matched
174
    without regard to case or separators.
167 175
- Every other open issue is `to_do`, including one whose only board column is
168 176
  `Done`, because the issue is still open.
169 177
170
Board visibility is the reader's own. A column on a board in a private
171
repository the reader is not a member of never becomes a fact about the issue.
178
Each input says when it stops counting, so a claim never reads as work
179
forever: an attempt's deadline passes, a session goes quiet, or somebody moves
180
the board column.
181
182
Visibility is the reader's own for every input. A column on a board in a
183
private repository the reader is not a member of, an owner-only session, and
184
an attempt withheld at its own transparency tier all leave the issue reading
185
`to_do`. Nothing here says *who* is working, only that work is under way.
172 186
173 187
```text
174 188
GET /api/v1/repos/:owner/:repo/issues?progress=in_progress
test/openagents/issue_progress_test.exs modified +281

@@ -1,9 +1,16 @@

1 1
defmodule OpenAgents.IssueProgressTest do
2 2
  use OpenAgents.DataCase
3 3
4
  import Ecto.Query
5
  import OpenAgentsWeb.ConnCase, only: [github_user: 1]
6
7
  alias OpenAgents.Forge.Assignment
4 8
  alias OpenAgents.Issues
5 9
  alias OpenAgents.ProjectItems
6 10
  alias OpenAgents.Projects
11
  alias OpenAgents.Threads
12
  alias OpenAgents.Threads.Thread
13
  alias OpenAgents.Transparency.ArtifactLink
7 14
8 15
  setup do
9 16
    {:ok, repository: repository_fixture()}

@@ -133,6 +140,280 @@ defmodule OpenAgents.IssueProgressTest do

133 140
    end
134 141
  end
135 142
143
  describe "an attempt holding the issue" do
144
    test "starts the issue with no board anywhere", %{repository: repository} do
145
      {:ok, issue} = Issues.create_issue(repository, %{title: "Picked up"})
146
      {:ok, untouched} = Issues.create_issue(repository, %{title: "Nobody has touched this"})
147
148
      attempt(repository, issue, state: "running")
149
150
      assert Issues.progress(issue) == "in_progress"
151
      assert Issues.progress(untouched) == "to_do"
152
    end
153
154
    test "an admitted attempt has started it too", %{repository: repository} do
155
      {:ok, issue} = Issues.create_issue(repository, %{title: "Admitted"})
156
      attempt(repository, issue, state: "admitted")
157
158
      assert Issues.progress(issue) == "in_progress"
159
    end
160
161
    test "a terminal attempt has released the claim", %{repository: repository} do
162
      for state <- ~w(completed failed cancelled) do
163
        {:ok, issue} = Issues.create_issue(repository, %{title: "Attempt #{state}"})
164
        attempt(repository, issue, state: state)
165
166
        assert Issues.progress(issue) == "to_do", "expected #{state} to stop claiming"
167
      end
168
    end
169
170
    test "an attempt past its deadline stops claiming work", %{repository: repository} do
171
      {:ok, issue} = Issues.create_issue(repository, %{title: "Ran over"})
172
173
      attempt(repository, issue,
174
        state: "running",
175
        deadline_at: DateTime.add(DateTime.utc_now(), -60, :second)
176
      )
177
178
      assert Issues.progress(issue) == "to_do"
179
    end
180
181
    test "a dark attempt is withheld rather than published as progress", %{
182
      repository: repository
183
    } do
184
      {:ok, issue} = Issues.create_issue(repository, %{title: "Withheld"})
185
      attempt(repository, issue, state: "running", transparency_tier: "dark")
186
187
      assert Issues.progress(issue) == "to_do"
188
    end
189
190
    test "a revoked artifact link takes the attempt out of the projection", %{
191
      repository: repository
192
    } do
193
      user = github_user("issue-progress-revoked")
194
      {:ok, issue} = Issues.create_issue(repository, %{title: "Revoked"})
195
196
      link =
197
        Repo.insert!(%ArtifactLink{
198
          account_id: user.id,
199
          artifact_type: "changelog",
200
          artifact_ref: "sha",
201
          repository_id: repository.id,
202
          tier: "ledger",
203
          revoked_at: DateTime.utc_now()
204
        })
205
206
      attempt(repository, issue, state: "running", artifact_link_id: link.id)
207
208
      assert Issues.progress(issue) == "to_do"
209
    end
210
211
    test "the filter lists exactly what the derived value calls started", %{
212
      repository: repository
213
    } do
214
      {:ok, started} = Issues.create_issue(repository, %{title: "Started"})
215
      {:ok, _queued} = Issues.create_issue(repository, %{title: "Queued"})
216
      attempt(repository, started, state: "running")
217
218
      assert {[found], 1} = Issues.list_issues_page(repository, progress: "in_progress")
219
      assert found.id == started.id
220
221
      {queued, _total} = Issues.list_issues_page(repository, progress: "to_do")
222
      assert Enum.map(queued, & &1.title) == ["Queued"]
223
    end
224
  end
225
226
  describe "a session bound to the issue" do
227
    test "an open thread starts the issue for the account running it", %{
228
      repository: repository
229
    } do
230
      user = github_user("issue-progress-owner")
231
      {:ok, issue} = Issues.create_issue(repository, %{title: "Being worked"})
232
      {:ok, untouched} = Issues.create_issue(repository, %{title: "Untouched"})
233
234
      {:ok, _thread} = Threads.open(user, "Implement it", issue_id: issue.id)
235
236
      assert Issues.progress(issue, user) == "in_progress"
237
      assert Issues.progress(untouched, user) == "to_do"
238
    end
239
240
    test "a thread that has gone quiet stops claiming the issue", %{repository: repository} do
241
      user = github_user("issue-progress-quiet")
242
      {:ok, issue} = Issues.create_issue(repository, %{title: "Walked away from"})
243
244
      {:ok, thread} = Threads.open(user, "Implement it", issue_id: issue.id)
245
      assert Issues.progress(issue, user) == "in_progress"
246
247
      go_quiet(thread)
248
249
      assert Issues.progress(issue, user) == "to_do"
250
      assert Repo.get!(Thread, thread.id).status == "open"
251
    end
252
253
    test "a finished thread stops claiming the issue", %{repository: repository} do
254
      user = github_user("issue-progress-finished")
255
      {:ok, issue} = Issues.create_issue(repository, %{title: "Session over"})
256
257
      {:ok, thread} = Threads.open(user, "Implement it", issue_id: issue.id)
258
      {:ok, _cancelled} = Threads.cancel(thread, "Done with it.")
259
260
      assert Issues.progress(issue, user) == "to_do"
261
    end
262
263
    test "an owner-only thread never becomes another reader's fact", %{repository: repository} do
264
      user = github_user("issue-progress-private")
265
      stranger = github_user("issue-progress-stranger")
266
      {:ok, issue} = Issues.create_issue(repository, %{title: "Privately worked"})
267
268
      {:ok, _dark} = Threads.open(user, "Implement it", issue_id: issue.id)
269
270
      assert Issues.progress(issue, user) == "in_progress"
271
      assert Issues.progress(issue, stranger) == "to_do"
272
      assert Issues.progress(issue, nil) == "to_do"
273
    end
274
275
    test "a thread opened at a wider tier reaches every reader", %{repository: repository} do
276
      user = github_user("issue-progress-wide")
277
      stranger = github_user("issue-progress-wide-stranger")
278
      {:ok, issue} = Issues.create_issue(repository, %{title: "Openly worked"})
279
280
      {:ok, _ledger} =
281
        Threads.open(user, "Implement it", issue_id: issue.id, visibility: "ledger")
282
283
      assert Issues.progress(issue, user) == "in_progress"
284
      assert Issues.progress(issue, stranger) == "in_progress"
285
      assert Issues.progress(issue, nil) == "in_progress"
286
    end
287
288
    test "the filter reads the same threads the derived value does", %{repository: repository} do
289
      user = github_user("issue-progress-filter")
290
      stranger = github_user("issue-progress-filter-stranger")
291
      {:ok, started} = Issues.create_issue(repository, %{title: "Started"})
292
      {:ok, _queued} = Issues.create_issue(repository, %{title: "Queued"})
293
294
      {:ok, _thread} = Threads.open(user, "Implement it", issue_id: started.id)
295
296
      assert {[found], 1} =
297
               Issues.list_issues_page(repository, progress: "in_progress", reader: user)
298
299
      assert found.id == started.id
300
301
      assert {[], 0} =
302
               Issues.list_issues_page(repository, progress: "in_progress", reader: stranger)
303
304
      {queued, _total} =
305
        Issues.list_issues_page(repository, progress: "to_do", reader: stranger)
306
307
      assert Enum.map(queued, & &1.title) |> Enum.sort() == ["Queued", "Started"]
308
    end
309
  end
310
311
  describe "binding a session to the issue its objective names" do
312
    test "an objective naming an issue in the thread's repository binds it", %{
313
      repository: repository
314
    } do
315
      user = github_user("issue-bind-owner")
316
      {:ok, issue} = Issues.create_issue(repository, %{title: "Named by the prompt"})
317
      path = "#{repository.owner}/#{repository.name}"
318
319
      {:ok, thread} =
320
        Threads.open(user, "Implement issue ##{issue.number} and close it", repository: path)
321
322
      assert thread.issue_id == issue.id
323
      assert Issues.progress(issue, user) == "in_progress"
324
    end
325
326
    test "the qualified form binds when it names the same repository", %{
327
      repository: repository
328
    } do
329
      user = github_user("issue-bind-qualified")
330
      {:ok, issue} = Issues.create_issue(repository, %{title: "Named in full"})
331
      path = "#{repository.owner}/#{repository.name}"
332
333
      {:ok, thread} = Threads.open(user, "Work #{path}##{issue.number}", repository: path)
334
335
      assert thread.issue_id == issue.id
336
    end
337
338
    test "a reference to another repository binds nothing", %{repository: repository} do
339
      user = github_user("issue-bind-cross")
340
      other = repository_fixture()
341
      {:ok, elsewhere} = Issues.create_issue(other, %{title: "Somewhere else"})
342
343
      {:ok, thread} =
344
        Threads.open(
345
          user,
346
          "Work #{other.owner}/#{other.name}##{elsewhere.number}",
347
          repository: "#{repository.owner}/#{repository.name}"
348
        )
349
350
      assert is_nil(thread.issue_id)
351
    end
352
353
    test "a thread naming no repository binds nothing", %{repository: repository} do
354
      user = github_user("issue-bind-unscoped")
355
      {:ok, issue} = Issues.create_issue(repository, %{title: "Unreachable"})
356
357
      {:ok, thread} = Threads.open(user, "Implement issue ##{issue.number}")
358
359
      assert is_nil(thread.issue_id)
360
    end
361
362
    test "an issue the opener cannot read binds nothing" do
363
      private = repository_fixture(%{visibility: "private"})
364
      outsider = github_user("issue-bind-outsider")
365
      {:ok, issue} = Issues.create_issue(private, %{title: "Private work"})
366
367
      {:ok, thread} =
368
        Threads.open(outsider, "Implement issue ##{issue.number}",
369
          repository: "#{private.owner}/#{private.name}"
370
        )
371
372
      assert is_nil(thread.issue_id)
373
    end
374
375
    test "a number naming no issue binds nothing", %{repository: repository} do
376
      user = github_user("issue-bind-missing")
377
378
      {:ok, thread} =
379
        Threads.open(user, "Implement issue #999999",
380
          repository: "#{repository.owner}/#{repository.name}"
381
        )
382
383
      assert is_nil(thread.issue_id)
384
    end
385
  end
386
387
  defp attempt(repository, issue, overrides) do
388
    now = DateTime.utc_now()
389
390
    defaults = [
391
      repository_id: repository.id,
392
      issue_id: issue.id,
393
      target_kind: "computer",
394
      requesting_principal: %{"kind" => "test"},
395
      branch: "work/#{issue.number}-#{System.unique_integer([:positive])}",
396
      state: "running",
397
      transparency_tier: "ledger",
398
      deadline_at: DateTime.add(now, 3600, :second),
399
      admitted_at: now,
400
      started_at: now
401
    ]
402
403
    Repo.insert!(struct!(Assignment, Keyword.merge(defaults, overrides)))
404
  end
405
406
  # Silence, not a status change: the thread stays open and simply records
407
  # nothing for longer than the quiet window.
408
  defp go_quiet(%Thread{id: id}) do
409
    quiet = DateTime.add(DateTime.utc_now(), -3 * 60 * 60, :second)
410
411
    {1, _} =
412
      Repo.update_all(from(thread in Thread, where: thread.id == ^id),
413
        set: [updated_at: quiet]
414
      )
415
  end
416
136 417
  defp place(board_repository, issue, column) do
137 418
    {:ok, project} =
138 419
      Projects.create_project(board_repository, %{title: "Board", owner: "OpenAgents"})
test/openagents/threads/grant_token_reach_test.exs modified +7

@@ -72,6 +72,13 @@ defmodule OpenAgents.Threads.GrantTokenReachTest do

72 72
    {:open_and_mint, 2} => :returns_plaintext_token,
73 73
    {:open_and_mint, 3} => :returns_plaintext_token,
74 74
    {:open_count, 1} => :scoped_by_owner,
75
    # Narrows a query rather than resolving anything. It is the tier predicate
76
    # `fetch_readable/2` and `list_for_issue/2` both compose, exported so a
77
    # caller outside this context — `OpenAgents.Issues.progress_map/2` — reads
78
    # threads through the same rule instead of writing a second one. It takes
79
    # no identifier and returns no thread, so it hands a caller nothing it did
80
    # not already have authority to ask for.
81
    {:readable_by, 2} => :tier_predicate,
75 82
    {:reap_expired, 1} => :scoped_by_owner,
76 83
    {:record_event, 3} => :thread_struct,
77 84
    {:record_events, 2} => :thread_struct,
test/openagents_web/live/issue_index_live_test.exs modified +23

@@ -264,6 +264,29 @@ defmodule OpenAgentsWeb.IssueIndexLiveTest do

264 264
    refute has_element?(view, ~s{#issues-#{queued.id} .issue-status[data-category="started"]})
265 265
  end
266 266
267
  # The workflow that produces almost all the movement here never opens a
268
  # board: an agent picks an issue up, works it, pushes, and closes it. The row
269
  # has to say so anyway (issue #254).
270
  test "a session working an issue renders the started arc with no board", %{conn: conn} do
271
    {:ok, started} = Issues.create_issue(repository(), %{"title" => "Being worked"})
272
    {:ok, queued} = Issues.create_issue(repository(), %{"title" => "Nobody has touched this"})
273
274
    # The reader is the account the setup logged in: the owner running the
275
    # session is the one looking at the list.
276
    {:ok, _thread} =
277
      OpenAgents.Threads.open(github_user("issue-index"), "Work it", issue_id: started.id)
278
279
    {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues")
280
281
    assert has_element?(
282
             view,
283
             ~s{#issues-#{started.id} .issue-status[data-category="started"] .issue-status__arc}
284
           )
285
286
    assert has_element?(view, ~s{#issues-#{queued.id} .issue-status[data-category="open"]})
287
    refute has_element?(view, ~s{#issues-#{queued.id} .issue-status[data-category="started"]})
288
  end
289
267 290
  defp repository do
268 291
    OpenAgents.Repositories.get_by_path!("OpenAgentsInc", "openagents.com")
269 292
  end

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