Public: anyone can read this; the crew can change it.
While "flow only" is on, only `flow` issues start builds, and assigning any other issue queues it #6970
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6393-while-flow-only-is-on-only-flow-issues-s → main 10 commits fork 8 threads · 8 unresolved
README.md +1 −1
| @@ -375,7 +375,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 375 | 375 | - [](docs/WEBSPEC-PREVIEWS.md) — the website specification run over every preview, report-only. |
| 376 | 376 | - [](docs/CREW.md) — the crew: agents, how a run works, review, live triage, the merge queue, spend, roles and invites, the sandbox, passports, and the direction a vision pull will write. |
| 377 | 377 | - [](docs/SENTINEL.md) — the forge hunting its own faults; [](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`). |
| 378 | - Flow only: a repository's admin (a person, never an agent) sets one switch from Settings → On its own, `PATCH /api/v1/repos/:owner/:name` with `flowOnly`, MCP `repo_settings`, or `w` on the terminal's About card (`I`); turning it on makes the `flow` label. While it is on, the repository header and its admin's home say so in one line (the line links to the setting for an admin only; on the home it appears under "Show everything", not the lean apps-first view). Nothing obeys it yet. | |
| 378 | - Flow only: a repository's admin (a person, never an agent) sets one switch from Settings → On its own, `PATCH /api/v1/repos/:owner/:name` with `flowOnly`, MCP `repo_settings`, or `w` on the terminal's About card (`I`); turning it on makes the `flow` label. While it is on, the repository header and its admin's home say so in one line (the line links to the setting for an admin only; on the home it appears under "Show everything", not the lean apps-first view). While it is on, builders start only `flow` issues; assigning any other issue is recorded but starts no run and reads "queued: flow only" (web, terminal, API and MCP `issue`) until it gets the `flow` label or the switch goes off, and then it dispatches. Rebase, re-check, red-fix and review runs always go. | |
| 379 | 379 | - Outsiders directing the crew: the terminal's About card (`I`) has a Crew row saying whether people who cannot write may direct the crew (the web page's words). A repository admin who is a person presses `d` to see the plan, then enter to confirm (esc changes nothing); it calls `setCrewOutsiders`, as MCP `outsiders` does. The key is `d`, not `o`, because `o` is the fork's upstream offer on the same card and `d` never clashes with it; a writer who is not an admin and an agent are not offered `d`. |
| 380 | 380 | - [](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h. |
| 381 | 381 | - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [](docs/FLOW.md). |
src/actions/issues.ts +5
| @@ -676,6 +676,11 @@ export function setIssueLabels( | ||
| 676 | 676 | actorId: writer.value.id, |
| 677 | 677 | payload: { number: issue.number, labels: resolved.map((l) => l.name) }, |
| 678 | 678 | }); |
| 679 | // FLOW ONLY held this assignment; the `flow` label lets it go now. | |
| 680 | // Loaded at the call: the crew's guard reaches this module through the brief, | |
| 681 | // so a static import of the dispatcher would close a cycle at boot. | |
| 682 | if (issue.assigneeIsAgent && repo.flowOnly) | |
| 683 | (require("../crew/flow_label.ts") as typeof import("../crew/flow_label.ts")).dispatchFlowLabelled(app, repo, issue); | |
Non-blocking: an inline |
||
| 679 | 684 | return ok(labelsFor(app.db, issue.id)); |
| 680 | 685 | } |
| 681 | 686 |
src/actions/repos.ts +4 −3
| @@ -22,6 +22,8 @@ import { | ||
| 22 | 22 | updateLabel as writeLabel, |
| 23 | 23 | type Label, |
| 24 | 24 | } from "../core/issues.ts"; |
| 25 | import { sweepAssigned } from "../crew/dispatch.ts"; | |
| 26 | import { FLOW_LABEL } from "../crew/flow_only.ts"; | |
| 25 | 27 | import { dropMirror } from "../crew/mirror.ts"; |
| 26 | 28 | import { accessAllows, getOrg, orgAccess } from "../core/orgs.ts"; |
| 27 | 29 | import { |
| @@ -334,9 +336,6 @@ function moveArchive(app: App, repoId: string, archived: boolean): Promise<boole | ||
| 334 | 336 | }); |
| 335 | 337 | } |
| 336 | 338 | |
| 337 | /** The label FLOW ONLY puts in a repository. */ | |
| 338 | const FLOW_LABEL = "flow"; | |
| 339 | ||
| 340 | 339 | /** |
| 341 | 340 | * Change settings. A new default branch must exist; git HEAD is moved first |
outdated
The const was moved to flow_only.ts but its doc comment stayed. It now sits above nothing. Delete it. |
||
| 342 | 341 | * and the row follows (git is truth, the index is derived from it). |
| @@ -378,6 +377,8 @@ export async function updateRepoSettings( | ||
| 378 | 377 | // FLOW ONLY names its label (the label it makes is not in `changed`, below): on, the repository has a `flow` to put on work. |
| 379 | 378 | if (settings.flowOnly === true && getLabel(app.db, repo.id, FLOW_LABEL) === undefined) |
| 380 | 379 | insertLabel(app.db, repo.id, FLOW_LABEL, "d29922"); |
| 380 | // Switched off: the assignments it held dispatch now. | |
| 381 | if (settings.flowOnly === false && repo.flowOnly) sweepAssigned(app); | |
|
||
| 381 | 382 | const moved = |
| 382 | 383 | archived === undefined ? false : await moveArchive(app, repo.id, archived); |
| 383 | 384 | const updated = getRepoById(app.db, repo.id); |
src/crew/dispatch.ts +9
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * time. A missed wake-up costs a tick, never a run. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { flowOnlyHolds } from "./flow_only.ts"; | |
| 11 | 12 | import { NO_MERGE } from "../checks/merged.ts"; |
| 12 | 13 | import { markHeard } from "../core/issues.ts"; |
| 13 | 14 | import { capacityCount, noteBooted } from "../core/capacity.ts"; |
| @@ -381,6 +382,14 @@ export function queueAgentRun( | ||
| 381 | 382 | }); |
| 382 | 383 | return undefined; |
| 383 | 384 | } |
| 385 | // FLOW ONLY: a builder's assignment on an issue not labelled `flow` waits | |
| 386 | // (recorded, not dispatched) until the label comes or the switch goes off. | |
| 387 | if ( | |
| 388 | reason === "assigned" && | |
| 389 | crewRole(app.db, repo.id, agent.user.id) === "builder" && | |
| 390 | flowOnlyHolds(app.db, repo, issue) | |
| 391 | ) | |
| 392 | return undefined; | |
| 384 | 393 | // Three failed or declined runs in an hour: parked, until a person speaks. |
| 385 | 394 | if (breakerTripped(app.db, repo, issue.id)) { |
| 386 | 395 | parkForBreaker(app, repo, issue, agent); |
src/crew/flow_label.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | /** The `flow` label's dispatch, apart from flow_only.ts so dispatch.ts can import that without a cycle. */ | |
| 2 | ||
| 3 | import type { App } from "../app.ts"; | |
| 4 | import { getAgent } from "../core/agents.ts"; | |
| 5 | import { getIssueById, type Issue } from "../core/issues.ts"; | |
| 6 | import type { Repo } from "../core/repos.ts"; | |
| 7 | import { queueAgentRun } from "./dispatch.ts"; | |
| 8 | import { flowOnlyHolds, hadAgentRun } from "./flow_only.ts"; | |
| 9 | ||
| 10 | /** | |
| 11 | * The `flow` label just reached an issue the switch was holding: start its | |
| 12 | * run, this issue only. The run is paid by whoever last assigned it, not by | |
| 13 | * the one who labelled, so the guest rule holds as it does in the sweep. | |
| 14 | */ | |
| 15 | export function dispatchFlowLabelled(app: App, repo: Repo, issue: Issue): void { | |
| 16 | const now = getIssueById(app.db, issue.id); | |
| 17 | if (now === undefined || now.state !== "open" || now.assigneeId === null) return; | |
| 18 | if (flowOnlyHolds(app.db, repo, now)) return; | |
| 19 | if (hadAgentRun(app.db, now.id)) return; | |
| 20 | const agent = getAgent(app.db, now.assigneeId); | |
| 21 | if (agent === undefined) return; | |
| 22 | const assigner = app.db | |
| 23 | .query<{ actor_id: string | null }, [string, string, number]>( | |
| 24 | `SELECT actor_id FROM event WHERE repo_id = ?1 AND kind = ?2 | |
| 25 | AND json_extract(payload, '$.number') = ?3 ORDER BY id DESC LIMIT 1`, | |
| 26 | ) | |
| 27 | .get(repo.id, `${now.kind}.assigned`, now.number); | |
| 28 | queueAgentRun(app, repo, now, agent, "assigned", assigner?.actor_id ?? undefined); | |
| 29 | } |
src/crew/flow_only.ts added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | /** | |
| 2 | * FLOW ONLY at the dispatcher: while a repository's switch is on, a builder | |
| 3 | * starts only issues labelled `flow`. A planner, a reviewer and the | |
| 4 | * direction issue are not builds, and a pull is never one: rebase, re-check, | |
| 5 | * red-fix and review runs go as ever. An assignment on a held issue is | |
| 6 | * recorded and waits; switching off sweeps it out (`sweepAssigned`). | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { labelsFor, type Issue } from "../core/issues.ts"; | |
| 11 | import type { Repo } from "../core/repos.ts"; | |
| 12 | ||
| 13 | export const FLOW_LABEL = "flow"; | |
| 14 | ||
| 15 | /** Whether the switch keeps a builder off `issue` now. */ | |
| 16 | export function flowOnlyHolds(db: Database, repo: Repo, issue: Issue): boolean { | |
| 17 | if (!repo.flowOnly || issue.kind !== "issue") return false; | |
| 18 | if (issue.id === repo.directionIssueId) return false; | |
| 19 | return !labelsFor(db, issue.id).some((l) => l.name.toLowerCase() === FLOW_LABEL); | |
| 20 | } | |
| 21 | ||
| 22 | /** The line an assigned issue wears while the switch holds its run; null when nothing is held. */ | |
| 23 | export function flowOnlyQueued(db: Database, repo: Repo, issue: Issue): string | null { | |
| 24 | if (issue.state !== "open" || !issue.assigneeIsAgent) return null; | |
| 25 | if (!flowOnlyHolds(db, repo, issue)) return null; | |
| 26 | return hadAgentRun(db, issue.id) ? null : "queued: flow only"; | |
| 27 | } | |
| 28 | ||
| 29 | /** Whether any agent run was ever started on the issue. */ | |
outdated
This count and the one in |
||
| 30 | export function hadAgentRun(db: Database, issueId: string): boolean { | |
outdated
Non-blocking: the same "has any agent run" COUNT appears here and in |
||
| 31 | const ran = db | |
| 32 | .query<{ n: number }, [string]>( | |
| 33 | "SELECT COUNT(*) AS n FROM run WHERE issue_id = ?1 AND kind = 'agent'", | |
| 34 | ) | |
| 35 | .get(issueId); | |
| 36 | return ran !== null && ran.n > 0; | |
| 37 | } |
src/crew/held.ts +6
| @@ -14,6 +14,8 @@ | ||
| 14 | 14 | * planner has not answered (`core/limb_replan.ts`). |
| 15 | 15 | * - `done_on_main`: the crew found it done on the base branch and waits for |
| 16 | 16 | * a person to close it (`done_on_main.ts`); nothing is left to plan. |
| 17 | * - `flow_only`: the repository's FLOW ONLY switch is on and the issue is not | |
| 18 | * labelled `flow` (`flow_only.ts`). | |
| 17 | 19 | * - `stalled`: a builder ran on it and left without a pull, and nobody |
| 18 | 20 | * who directs the crew has spoken since. The next tick parks it in the |
| 19 | 21 | * crew's name, which frees it (`stalls.ts`). |
| @@ -41,6 +43,7 @@ import { getUserById } from "../core/users.ts"; | ||
| 41 | 43 | import type { Stuck } from "./brief.ts"; |
| 42 | 44 | import { proposedWork, speaksForCrew } from "./direct.ts"; |
| 43 | 45 | import { capReached, runCapOf } from "./guard.ts"; |
| 46 | import { flowOnlyHolds } from "./flow_only.ts"; | |
| 44 | 47 | import { interruptionOf } from "./interrupted.ts"; |
| 45 | 48 | import { PARK_STRIKES, parkedCount } from "./parks.ts"; |
| 46 | 49 | import { heldFiles, type FileHold } from "./file_holds.ts"; |
| @@ -53,6 +56,7 @@ export const HOLDS = [ | ||
| 53 | 56 | "planner", |
| 54 | 57 | "with_planner", |
| 55 | 58 | "done_on_main", |
| 59 | "flow_only", | |
| 56 | 60 | "stalled", |
| 57 | 61 | "run_cap", |
| 58 | 62 | "parked_twice", |
| @@ -143,6 +147,7 @@ export function heldBy( | ||
| 143 | 147 | if (issue.assigneeId !== null && planners.has(issue.assigneeId)) return "planner"; |
| 144 | 148 | if (handed.has(issue.id)) return "with_planner"; |
| 145 | 149 | if (alreadyDone(app.db, [issue.id]).has(issue.id)) return "done_on_main"; |
| 150 | if (flowOnlyHolds(app.db, repo, issue)) return "flow_only"; | |
| 146 | 151 | if (stalled(app, repo, issue)) return "stalled"; |
| 147 | 152 | if (atRunCap(app, repo, issue)) return "run_cap"; |
| 148 | 153 | if (parkedCount(app.db, repo, issue.id) >= PARK_STRIKES) return "parked_twice"; |
| @@ -227,6 +232,7 @@ const HOLD_WORDS: Readonly<Record<HoldName, string>> = { | ||
| 227 | 232 | planner: "the planner or a reviewer holds it", |
| 228 | 233 | with_planner: "its limb waits on the planner", |
| 229 | 234 | done_on_main: "found done on the base branch; waits for a person to close it", |
| 235 | flow_only: "flow only is on and it is not labelled flow", | |
| 230 | 236 | stalled: "a builder ran on it and left without a pull", |
| 231 | 237 | run_cap: "it has used its runs for the day", |
| 232 | 238 | parked_twice: "parked twice; it is the planner's", |
src/http/api.ts +5
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * back to continue); errors are `{ error, message }`. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { flowOnlyQueued } from "../crew/flow_only.ts"; | |
| 11 | 12 | import { homeTruth } from "../system/home_truth.ts"; |
| 12 | 13 | import { upstreamFacts } from "../actions/upstream_follow.ts"; |
| 13 | 14 | import { FAMILY_WORDS } from "../actions/family_words.ts"; |
| @@ -492,9 +493,13 @@ function respondFleet(error: FleetError): Response { | ||
| 492 | 493 | } |
| 493 | 494 | |
| 494 | 495 | export function withLabels<T extends Issue>(ctx: Ctx, issue: T) { |
| 496 | const repo = getRepoById(ctx.app.db, issue.repoId); | |
| 497 | const queued = repo === undefined ? null : flowOnlyQueued(ctx.app.db, repo, issue); | |
| 495 | 498 | return { |
| 496 | 499 | ...readerMarked(ctx, issue.repoId, [issue])[0]!, |
| 497 | 500 | labels: labelsFor(ctx.app.db, issue.id), |
| 501 | // FLOW ONLY holds an assigned run: say so, so an assigner knows nothing started. | |
| 502 | ...(queued === null ? {} : { queued }), | |
| 498 | 503 | }; |
| 499 | 504 | } |
| 500 | 505 |
src/http/mcp/tools/issue.ts +3
| @@ -3,6 +3,7 @@ import { countThread, EMBEDDED_COMMENTS, getIssue, labelsFor, listComments } fro | ||
| 3 | 3 | import { isCrew } from "../../../core/agents.ts"; |
| 4 | 4 | import { dependenciesOf } from "../../../core/dependencies.ts"; |
| 5 | 5 | import { getUsersByIds } from "../../../core/users.ts"; |
| 6 | import { flowOnlyQueued } from "../../../crew/flow_only.ts"; | |
| 6 | 7 | import { markReaders } from "../../../crew/direct.ts"; |
| 7 | 8 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 8 | 9 | |
| @@ -53,6 +54,7 @@ export const tool: Tool = { | ||
| 53 | 54 | const seen = (i: typeof issue) => mayView(db, repo, i, user); |
| 54 | 55 | const flag = (x: { untrusted?: string }) => |
| 55 | 56 | x.untrusted === undefined ? {} : { untrusted: true }; |
| 57 | const queued = flowOnlyQueued(db, repo, issue); | |
| 56 | 58 | const [self] = mark([issue]); |
| 57 | 59 | const answer = good({ |
| 58 | 60 | number: issue.number, |
| @@ -65,6 +67,7 @@ export const tool: Tool = { | ||
| 65 | 67 | ...flag(self!), |
| 66 | 68 | labels: labelsFor(db, issue.id).map((l) => l.name), |
| 67 | 69 | assignee: issue.assigneeName, |
| 70 | ...(queued === null ? {} : { queued }), | |
| 68 | 71 | comments: comments.map((c) => ({ |
| 69 | 72 | author: c.authorName, |
| 70 | 73 | authorIsAgent: authors.get(c.authorId)?.kind === "agent", |
src/tui/screens/assign.ts +12
| @@ -15,6 +15,8 @@ | ||
| 15 | 15 | import { listCrew } from "../../actions/crew.ts"; |
| 16 | 16 | import type { Agent } from "../../core/agents.ts"; |
| 17 | 17 | import { assignIssue } from "../../actions/issues.ts"; |
| 18 | import { flowOnlyQueued } from "../../crew/flow_only.ts"; | |
| 19 | import type { Database } from "bun:sqlite"; | |
| 18 | 20 | import type { Issue } from "../../core/issues.ts"; |
| 19 | 21 | import { canRead, type Repo } from "../../core/repos.ts"; |
| 20 | 22 | import { getUserById, listPeople, type User } from "../../core/users.ts"; |
| @@ -56,6 +58,16 @@ export function assignedText(issue: Issue | undefined, set: IconSet): string { | ||
| 56 | 58 | : ` · assigned to ${issue.assigneeName} (agent)`; |
| 57 | 59 | } |
| 58 | 60 | |
| 61 | /** ` · queued: flow only` while FLOW ONLY holds an assigned agent's run; empty otherwise. */ | |
| 62 | export function queuedText( | |
| 63 | db: Database, | |
| 64 | repo: Repo | undefined, | |
| 65 | issue: Issue | undefined, | |
| 66 | ): string { | |
| 67 | const held = repo === undefined || issue === undefined ? null : flowOnlyQueued(db, repo, issue); | |
| 68 | return held === null ? "" : ` · ${held}`; | |
| 69 | } | |
| 70 | ||
| 59 | 71 | /** How many people the picker reads before it stops; `listPeople`'s ceiling. */ |
| 60 | 72 | const PEOPLE_LIMIT = 200; |
| 61 | 73 |
src/tui/screens/issue.ts +2 −2
| @@ -59,7 +59,7 @@ import { | ||
| 59 | 59 | promptScreen, |
| 60 | 60 | runAsync, |
| 61 | 61 | } from "./common.ts"; |
| 62 | import { assignPicker, assignedText } from "./assign.ts"; | |
| 62 | import { assignPicker, assignedText, queuedText } from "./assign.ts"; | |
| 63 | 63 | import { liveBindings, liveKey } from "./mirror.ts"; |
| 64 | 64 | import { RunList, openRunLog } from "./runlist.ts"; |
| 65 | 65 | import { Composer, threadLines, type ThreadEntry } from "./thread.ts"; |
| @@ -428,7 +428,7 @@ export function issueScreen( | ||
| 428 | 428 | x, |
| 429 | 429 | head!.y + 1, |
| 430 | 430 | truncate( |
| 431 | ` ${issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)} · ${plural(detail.comments.length, "comment")}${assignedText(issueRow, ctx.icons)}${doneText(doneBy, mayWrite())}${pictureText()}`, | |
| 431 | ` ${issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)} · ${plural(detail.comments.length, "comment")}${assignedText(issueRow, ctx.icons)}${queuedText(f.db, repo, issueRow)}${doneText(doneBy, mayWrite())}${pictureText()}`, | |
| 432 | 432 | Math.max(0, right - x), |
| 433 | 433 | ), |
| 434 | 434 | style(c.mutedFg, c.bg), |
src/web/pages/thread.ts +3 −1
| @@ -73,6 +73,7 @@ import { | ||
| 73 | 73 | } from "../ui.ts"; |
| 74 | 74 | import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts"; |
| 75 | 75 | import { studioRecord, tellHref } from "../studio/nouns.ts"; |
| 76 | import { flowOnlyQueued } from "../../crew/flow_only.ts"; | |
| 76 | 77 | import { interruptionsOn } from "../../crew/interrupted.ts"; |
| 77 | 78 | |
| 78 | 79 | /** |
| @@ -723,6 +724,7 @@ export function assigneeCard( | ||
| 723 | 724 | action: string, |
| 724 | 725 | ): Html { |
| 725 | 726 | const mayEdit = canWrite(ctx.app.db, repo, ctx.auth.user); |
| 727 | const held = flowOnlyQueued(ctx.app.db, repo, issue); | |
This state exists only in the page. Expose it to the tty and the API/MCP issue view too, and add a test. |
||
| 726 | 728 | const current = |
| 727 | 729 | issue.assigneeName === null |
| 728 | 730 | ? html`<p class="muted small">Nobody yet.</p>` |
| @@ -733,7 +735,7 @@ export function assigneeCard( | ||
| 733 | 735 | issue.assigneeName, |
| 734 | 736 | ), |
| 735 | 737 | })} |
| 736 | </p>`; | |
| 738 | </p>${held === null ? null : html`<p class="muted small" id="flow-only-queued">${held}</p>`}`; | |
No test reaches this line. Add a page GET that asserts |
||
| 737 | 739 | if (!mayEdit) |
| 738 | 740 | return card(current, { |
| 739 | 741 | head: html`${icon("user")} |
test/flow-only-dispatch.test.ts added +151
| @@ -0,0 +1,151 @@ | ||
| 1 | /** | |
| 2 | * FLOW ONLY stops builds, not just words: a builder starts only `flow` | |
| 3 | * issues while the switch is on, an assignment on any other waits and reads | |
| 4 | * "queued: flow only", and switching off dispatches it. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 12 | import { assignIssue, openIssue, setIssueLabels as labelIssue } from "../src/actions/issues.ts"; | |
| 13 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 14 | import { getAgent } from "../src/core/agents.ts"; | |
| 15 | import { getLabel, setIssueLabels, type Issue } from "../src/core/issues.ts"; | |
| 16 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 17 | import { listActive } from "../src/core/runs.ts"; | |
| 18 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 19 | import { heldNow } from "../src/crew/held.ts"; | |
| 20 | import { flowOnlyQueued } from "../src/crew/flow_only.ts"; | |
| 21 | import { createSession } from "../src/core/sessions.ts"; | |
| 22 | import { createToken } from "../src/core/tokens.ts"; | |
| 23 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 24 | import { queuedText } from "../src/tui/screens/assign.ts"; | |
| 25 | import { queueAgentRun, sweepAssigned } from "../src/crew/dispatch.ts"; | |
| 26 | ||
| 27 | describe("flow only at the dispatcher", () => { | |
| 28 | let dataDir: string; | |
| 29 | let app: App; | |
| 30 | let alice: User; | |
| 31 | let repo: Repo; | |
| 32 | let bot: User; | |
| 33 | let servers: Servers; | |
| 34 | ||
| 35 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 36 | const open = (title: string): Issue => { | |
| 37 | const o = openIssue(app, alice, fresh(), { title, body: "" }); | |
| 38 | if (!o.ok) throw new Error(String(o.error)); | |
| 39 | return o.value; | |
| 40 | }; | |
| 41 | const runsOn = (issue: Issue) => | |
| 42 | listActive(app.db, "agent").filter((r) => r.issueId === issue.id).length; | |
| 43 | const assign = (issue: Issue): Issue => { | |
| 44 | const a = assignIssue(app, alice, fresh(), issue, bot); | |
| 45 | if (!a.ok) throw new Error(String(a.error)); | |
| 46 | return a.value; | |
| 47 | }; | |
| 48 | const flow = async (on: boolean) => { | |
| 49 | const set = await updateRepoSettings(app, alice, fresh(), { flowOnly: on }); | |
| 50 | if (!set.ok) throw new Error(JSON.stringify(set.error)); | |
| 51 | }; | |
| 52 | ||
| 53 | beforeAll(async () => { | |
| 54 | dataDir = await mkdtemp(join(tmpdir(), "forge-flow-only-dispatch-")); | |
| 55 | app = createApp({ dataDir, port: 0 }); | |
| 56 | const a = await createUser(app.db, { | |
| 57 | origin: "seed", | |
| 58 | trusted: true, | |
| 59 | username: "alice", | |
| 60 | email: "[email protected]", | |
| 61 | password: "a-good-passphrase", | |
| 62 | adminIfFirst: true, | |
| 63 | }); | |
| 64 | if (!a.ok) throw new Error(a.error); | |
| 65 | alice = a.value; | |
| 66 | servers = await serve(app); | |
| 67 | const made = await createRepo(app, alice, { name: "svc", visibility: "private" }); | |
| 68 | if (!made.ok) throw new Error(String(made.error)); | |
| 69 | repo = made.value; | |
| 70 | const created = await createAgent(app, alice, { username: "bot" }); | |
| 71 | if (!created.ok) throw new Error(String(created.error)); | |
| 72 | expect(addToCrew(app, alice, repo, "bot").ok).toBe(true); | |
| 73 | bot = getUserByName(app.db, "bot")!; | |
| 74 | }); | |
| 75 | ||
| 76 | afterAll(async () => { | |
| 77 | servers.stop(); | |
| 78 | app.close(); | |
| 79 | await rm(dataDir, { recursive: true, force: true }); | |
| 80 | }); | |
| 81 | ||
| 82 | test("a non-flow assignment waits while on, goes when off; a flow issue and a rebase run go on", async () => { | |
| 83 | await flow(true); | |
| 84 | const plain = assign(open("Plain")); | |
| 85 | sweepAssigned(app); | |
| 86 | expect(runsOn(plain)).toBe(0); | |
| 87 | expect(flowOnlyQueued(app.db, fresh(), plain)).toBe("queued: flow only"); | |
| 88 | expect(heldNow(app, fresh()).by.flow_only.map((i) => i.id)).toContain(plain.id); | |
| 89 | ||
| 90 | const labelled = open("Labelled"); | |
| 91 | const label = getLabel(app.db, repo.id, "flow")!; | |
| 92 | setIssueLabels(app.db, labelled.id, [label.id]); | |
| 93 | const flowing = assign(labelled); | |
| 94 | sweepAssigned(app); | |
| 95 | expect(runsOn(flowing)).toBe(1); | |
| 96 | expect(flowOnlyQueued(app.db, fresh(), flowing)).toBeNull(); | |
| 97 | expect(heldNow(app, fresh()).by.flow_only.map((i) => i.id)).not.toContain(labelled.id); | |
| 98 | ||
| 99 | // A rebase run on the held issue is not a build. | |
| 100 | const agent = getAgent(app.db, bot.id)!; | |
| 101 | const other = open("Other"); | |
| 102 | expect(queueAgentRun(app, fresh(), other, agent, "conflict")).toBeDefined(); | |
| 103 | ||
| 104 | await flow(false); | |
| 105 | expect(runsOn(plain)).toBe(1); | |
| 106 | expect(flowOnlyQueued(app.db, fresh(), plain)).toBeNull(); | |
| 107 | }); | |
| 108 | ||
| 109 | test("the held state shows on the API, the terminal and the MCP, and the flow label dispatches it", async () => { | |
| 110 | await flow(true); | |
| 111 | const held = assign(open("Waits")); | |
| 112 | sweepAssigned(app); | |
| 113 | expect(queuedText(app.db, fresh(), held)).toBe(" · queued: flow only"); | |
| 114 | ||
| 115 | const bearer = { authorization: `Bearer ${createToken(app.db, alice.id, "t", "admin").token}` }; | |
| 116 | const api = (await (await fetch(`${servers.url}/api/v1/repos/alice/svc/issues/${held.number}`, { headers: bearer })).json()) as any; | |
| 117 | expect(api.queued).toBe("queued: flow only"); | |
| 118 | const res = await fetch(`${servers.url}/mcp`, { | |
| 119 | method: "POST", | |
| 120 | headers: { ...bearer, "content-type": "application/json", accept: "application/json, text/event-stream" }, | |
| 121 | body: JSON.stringify({ | |
| 122 | jsonrpc: "2.0", | |
| 123 | id: 1, | |
| 124 | method: "tools/call", | |
| 125 | params: { name: "issue", arguments: { repo: "alice/svc", number: held.number } }, | |
| 126 | }), | |
| 127 | }); | |
| 128 | expect(JSON.parse(((await res.json()) as any).result.content[0].text).queued).toBe("queued: flow only"); | |
| 129 | ||
| 130 | const page = (await (await fetch(`${servers.url}/alice/svc/issues/${held.number}`, { headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` } })).text()); | |
| 131 | expect(page).toContain('id="flow-only-queued"'); | |
| 132 | ||
| 133 | // An assigned, never-run issue elsewhere waits for its own reasons; a label here must not start it. | |
| 134 | const made = await createRepo(app, alice, { name: "other", visibility: "private" }); | |
| 135 | if (!made.ok) throw new Error(String(made.error)); | |
| 136 | expect(addToCrew(app, alice, made.value, "bot").ok).toBe(true); | |
| 137 | const o = openIssue(app, alice, made.value, { title: "Elsewhere", body: "" }); | |
| 138 | if (!o.ok) throw new Error(String(o.error)); | |
| 139 | const elsewhere = assignIssue(app, alice, made.value, o.value, bot); | |
| 140 | expect(elsewhere.ok).toBe(true); | |
| 141 | ||
| 142 | // Labelling it `flow` lets it go now, with the switch still on. | |
| 143 | const labelled = labelIssue(app, alice, fresh(), held, ["flow"]); | |
| 144 | expect(labelled.ok).toBe(true); | |
| 145 | expect(runsOn(held)).toBe(1); | |
| 146 | expect(runsOn(o.value)).toBe(0); | |
| 147 | expect(queuedText(app.db, fresh(), held)).toBe(""); | |
| 148 | const after = await (await fetch(`${servers.url}/alice/svc/issues/${held.number}`, { headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` } })).text(); | |
| 149 | expect(after).not.toContain('id="flow-only-queued"'); | |
| 150 | }); | |
| 151 | }); |
Medium: this runs the global boot sweep (100 rows across every repository, with an event subquery per row) on every label edit, and it can dispatch waiting assignments in other repositories. Queue just this issue instead:
queueAgentRun(app, repo, issue, agent, "assigned", lastAssigner), and only when it now carriesflowand has no agent run yet.