Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions lab/internal/ui/drive.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ func (h *handler) drive(w http.ResponseWriter, r *http.Request, id, action strin
}
switch action {
case "run":
if h.refuseIssueNotOpen(r.Context(), w, run.Issue) {
return
}
h.startRun(w, run)
case "loop/start":
h.startLoop(w, loop)
Expand Down Expand Up @@ -204,6 +207,79 @@ func (h *handler) startRun(w http.ResponseWriter, req runRequest) {
h.json(w, http.StatusAccepted, h.runView(req.Issue, job.snapshot(&h.jobs.mu)))
}

// refuseIssueNotOpen refuses Start intake unless GitHub, read again now, and the
// ledgers make the Issue startable by the sync's one rule: its state is built
// exactly as /api/issues builds the row (queue summary, forgesync.SyncState,
// ledger completeness, staleness), so only rows 1, 5, 10 and 15 start. An Issue
// closed since the Issues view was shown, unreadable, not read, read from a
// failed sync or read by a request that went away is never started. It checks
// only when the active Formula names a repository, as /api/issues does; an
// invalid Issue number is left to startRun. The refusals startRun makes without
// GitHub (no source checkout, a resource floor, a running Molecule) come first,
// so a start that cannot run never reads GitHub. The refresh is one shared sync
// of the same Want the sync routes read, plus this Issue, so their snapshot
// stays whole.
func (h *handler) refuseIssueNotOpen(ctx context.Context, w http.ResponseWriter, issue int64) bool {
if issue < 1 || issue > maxIssueNumber {
return false
}
repo := h.syncRepository()
if repo == "" {
return false
}
if h.sourceMissing(w) || h.belowFloor(w) {
return true
}
if h.activeMolecule(issue) {
h.refuseGate(w, http.StatusConflict, "busy", "the ledgers already show a running Molecule for this Issue; nothing was started")
return true
}
start := h.forge.now()
work := h.syncWork(repo)
want := work.want
want.Issues = append(append([]int64{}, want.Issues...), issue)
res := h.forge.cache.Get(ctx, want, true)
// A forced Get that joined a flight begun before this request returns that
// older read: one more forced Get reads GitHub after start.
if ctx.Err() == nil && res.Snapshot != nil && res.Snapshot.FetchedAt.Before(start) {
res = h.forge.cache.Get(ctx, want, true)
}
found := res.Snapshot.Issue(issue)
switch {
case ctx.Err() != nil || !res.Current() || found == nil || res.Snapshot.FetchedAt.Before(start):
// A request that went away gets the previous snapshot back, never a
// current read: it is not checked.
h.refuseWith(w, http.StatusConflict, RefusedIssueNotChecked, "GitHub state for this Issue could not be checked just now; nothing was started", "Refresh the Issues view, then start intake again once GitHub state is current")
case !found.Found:
h.refuseWith(w, http.StatusConflict, RefusedIssueUnavailable, "GitHub does not show this Issue in the active Formula's repository; nothing was started", "Check the Issue on GitHub; a transferred or deleted Issue cannot be started here")
case found.State != forgesync.IssueOpen:
h.refuseWith(w, http.StatusConflict, RefusedIssueClosed, "the Issue is closed on GitHub; nothing was started", "Reopen the Issue on GitHub if the work is still wanted, then Refresh and start intake again")
default:
queue := molecule.ReadQueueInventory(h.opts.LabDir)
if !queue.Complete || !work.list.Complete {
h.refuseWith(w, http.StatusConflict, RefusedInventoryIncomplete, "some of the Lab's ledgers could not be read, so no Issue can be started; nothing was started", "Fix or remove the unreadable ledgers the Work view names, then start intake again")
return true
}
summary := queue.Summarize(repo, issue)
state := forgesync.SyncState(found, res.Snapshot.PRs, forgesync.Work{Molecules: work.byIssue[issue], SummaryStartable: summary.Startable()})
if state.Startable {
return false
}
h.refuseWith(w, http.StatusConflict, RefusedIssueNotStartable, "Forgecell already tracks work for this Issue, so a fresh start is not allowed; nothing was started", "Open the Issue's Molecule from the Issues view to see what it needs")
}
return true
}

// Refusal categories of Start intake when the sync does not make the Issue
// startable.
const (
RefusedIssueClosed = "issue-closed"
RefusedIssueUnavailable = "issue-unavailable"
RefusedIssueNotChecked = "issue-not-checked"
RefusedIssueNotStartable = "issue-not-startable"
RefusedInventoryIncomplete = "inventory-incomplete"
)

func active(status string) bool { return status == "running" || status == "allocating" }

// activeMolecule reports a ledger that shows a running Molecule for issue.
Expand Down
8 changes: 5 additions & 3 deletions lab/internal/ui/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ type issueSync struct {
// repository is the active Formula's repository; it never comes from a
// request.
repository func(lab string) string
// now is the cache's clock, so Start intake can tell a snapshot read after
// it began from an older one and the loop view can age the last answer.
now func() time.Time
receiptsMu sync.Mutex
receipts map[string]publishedEntry
// now is the cache's clock; last is the latest answer get returned, which
// the loop view reads without starting a sync.
now func() time.Time
// last is the latest answer get returned, which the loop view reads
// without starting a sync.
lastMu sync.Mutex
last forgesync.Result
}
Expand Down
5 changes: 4 additions & 1 deletion lab/internal/ui/issues_demo.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func demoIssues() IssuesView {
// with sample GitHub state. Finished sample Molecules cycle through a merged
// pull request with a closed Issue, a draft pull request and a closed one.
func demoSync() SyncView {
out := SyncView{Sample: true, Repository: demoRepo, Freshness: Freshness{Checked: true, FetchedAt: "2026-10-26T12:00:00Z"}, Molecules: map[string]forgesync.DisplayState{}, Counts: map[string]int{}}
out := SyncView{Sample: true, Repository: demoRepo, Freshness: Freshness{Checked: true, FetchedAt: "2026-10-26T12:00:00Z"}, Molecules: map[string]forgesync.DisplayState{}, Counts: map[string]int{}, NeedsYou: map[string]bool{}}
for _, s := range demoList().Molecules {
m := forgesync.Molecule{ID: s.ID, Lane: s.Lane, Step: s.CurrentAtom}
issue := demoIssue(s.Number, s.Title, forgesync.IssueOpen, "")
Expand All @@ -105,6 +105,9 @@ func demoSync() SyncView {
if d.Bucket != forgesync.BucketRetired {
out.Counts[d.Bucket]++
}
if needsYou(d) {
out.NeedsYou[s.ID] = true
}
}
return out
}
5 changes: 3 additions & 2 deletions lab/internal/ui/shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,9 @@ func TestUIShellServedPageHasViewsNavAndTabBar(t *testing.T) {
t.Errorf("panel %s hidden=%t", panel[1], hidden)
}
}
if !strings.Contains(between(t, page, `id="view-issues"`, "</section>"), "Coming next") {
t.Error("view-issues is not a coming-next placeholder")
// The Issues view is built (#266): its containers replace the placeholder.
if issues := between(t, page, `id="view-issues"`, "</section>"); strings.Contains(issues, "Coming next") || !strings.Contains(issues, `<div id="issues-rows" role="rowgroup"></div>`) {
t.Error("view-issues is still a coming-next placeholder")
}
// The Inbox is built (#250): its containers replace the placeholder.
if inbox := between(t, page, `id="view-inbox"`, `id="view-issues"`); strings.Contains(inbox, "Coming next") {
Expand Down
13 changes: 12 additions & 1 deletion lab/internal/ui/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ type SyncView struct {
Molecules map[string]forgesync.DisplayState `json:"molecules"`
// Counts tallies the buckets of the non-retired Molecules.
Counts map[string]int `json:"counts"`
// NeedsYou is every Molecule whose state needs you (bucket decision or
// blocked), so no surface re-derives it.
NeedsYou map[string]bool `json:"needsYou"`
}

// needsYou reports a state the counts header files under "need you".
func needsYou(d forgesync.DisplayState) bool {
return d.Bucket == forgesync.BucketDecision || d.Bucket == forgesync.BucketBlocked
}

func (h *handler) syncStates(w http.ResponseWriter, r *http.Request) {
Expand All @@ -27,7 +35,7 @@ func (h *handler) syncStates(w http.ResponseWriter, r *http.Request) {
return
}
repo := h.syncRepository()
out := SyncView{Repository: repo, Molecules: map[string]forgesync.DisplayState{}, Counts: map[string]int{}}
out := SyncView{Repository: repo, Molecules: map[string]forgesync.DisplayState{}, Counts: map[string]int{}, NeedsYou: map[string]bool{}}
work := h.syncWork(repo)
var res forgesync.Result
if repo == "" {
Expand Down Expand Up @@ -59,6 +67,9 @@ func (h *handler) syncStates(w http.ResponseWriter, r *http.Request) {
if d.Bucket != forgesync.BucketRetired {
out.Counts[d.Bucket]++
}
if needsYou(d) {
out.NeedsYou[s.ID] = true
}
}
h.json(w, http.StatusOK, out)
}
Loading
Loading