Public: anyone can read this; the crew can change it.
The asks list holds every pull waiting on a person: handed over, or untouched by a person for 24 h #4176
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3256-the-asks-list-holds-every-pull-waiting-o → main 7 commits fork 2 threads · 2 unresolved
7 files
changed +490
−10
README.md +2
| @@ -821,6 +821,8 @@ and `forge sync` below is what acts on it. | ||
| 821 | 821 | |
| 822 | 822 | **What an ask row waits for.** Every ask row carries one word, `waitingFor` (`src/core/waiting_for.ts`, computed once), and who it waits on, e.g. "ready to land · on you". The first that applies wins: `conflicts with main` (the last sweep found files stopping the pull, on its author), `ready to land` (a green change to look at and ship), `a review` (a review or a hand-over), `assigned to you` (the pull or issue is assigned to the viewer), otherwise `a decision`. It shows as a chip on each row of `/asks`, as `waitingFor: {word, on}` in `GET /api/v1/asks` and the MCP `asks` tool, and in the terminal's On you card. A grouped row shares one word (the weightiest of its items), and the terminal's card shows its row's word. |
| 823 | 823 | |
| 824 | **Waiting.** A pull waits on a person when the crew handed it over, or when no person has acted on it for 24 hours (`WAIT_LIMIT_MS`, `src/core/pull_wait.ts`): a person's opening, comment or review restarts the clock, the crew's own words do not. The row is "Waiting" with how long (`waiting 30 hours`); it is on `/asks`, `GET /api/v1/asks` (`kind: "waiting"`, `why`), MCP `asks`, and in the terminal's On you card (`waiting` before the title). A pull you hold never asks for being idle, and a crew pull still at work or younger than a day stays the crew's. It can be picked, shipped or closed with the other rows (`waiting:<id>`). A crew member can advise by ending a comment with one line, `Recommendation: merge|close|change — why`; the newest such line from a non-person is shown as "The crew advises: merge — why" on the page and the terminal row, and as `recommendation: {verdict, reason}` in the API, on handed-over rows too. | |
| 825 | ||
| 824 | 826 | **Muting a repository from /asks**: each ask row on `/asks` carries a "Mute owner/name" chip. A muted repository's asks leave `/asks`, `/api/v1/asks` and the phone's pushes (the same per-user mute the On you card uses, no new table); `/asks` says "N muted repositories still ask" with a "Listen again" button, and an app's own page still counts them. The home itself renders no asks, no for-you list and no count: the top bar carries a plain Asks link to `/asks`, and the terminal's dashboard keeps its On you card on purpose (a different door). |
| 825 | 827 | |
| 826 | 828 | **A wave proposal is one card.** With "plans need a nod" on, a plan the planner writes is kept as a proposal and opens nothing. It is one row in the asks list (`/asks`, `/api/v1/asks`, MCP `asks`, the terminal's On you card) with its title, its why and its issue titles, all escaped, and it asks the app's owner only. Three taps, each one POST that leaves you on the page you were on: **Approve** opens the issues with their links, exactly as an unheld plan would; **Redirect** takes a text box and hands your words to the planner as a new run on the direction issue; **Later** hides it until you bring it back, from the "N waves put aside" line under the asks on `/asks` (each with a "Bring back" button). A proposal is decided once. The same four verbs (`approve`, `redirect`, `later`, `again`) are on every door, all calling `actions/proposals.ts`: the page (`/asks/proposal/<id>/<verb>`), the API (`POST /api/v1/asks/proposal/<id>/<verb>`, a `rw` token, `{"words": …}` for redirect), the MCP tool `asks_proposal` (`id`, `verb`, `words`), and in the terminal `y` approve, `R` redirect, `l` later and `B` bring every put-aside wave back, on a wave row of the On you card. |
src/core/pull_wait.ts added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | /** | |
| 2 | * How long an open pull has waited on a person, and what the crew advised. | |
| 3 | * A pull no person has touched for a day is a person's to decide, whatever | |
| 4 | * its check says; the crew's advice is a line it wrote in the thread, | |
| 5 | * `Recommendation: merge|close|change — why`, read here and nowhere else. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import type { Issue } from "./issues.ts"; | |
| 10 | ||
| 11 | /** A day, in ms: how long a pull may go without a person before it asks one. */ | |
| 12 | export const WAIT_LIMIT_MS = 24 * 3_600_000; | |
| 13 | ||
| 14 | export type Verdict = "merge" | "close" | "change"; | |
| 15 | ||
| 16 | export interface Recommendation { | |
| 17 | readonly verdict: Verdict; | |
| 18 | readonly reason: string; | |
| 19 | } | |
| 20 | ||
| 21 | const CHUNK = 400; | |
| 22 | const LINE = /^\s*recommendation:\s*(merge|close|change)\b[\s:—–-]*(.*)$/im; | |
| 23 | ||
| 24 | /** | |
| 25 | * When a person last acted on each pull, or its start if none has: the | |
| 26 | * author's opening (when a person opened it), and any comment or review of | |
| 27 | * a person. One query per chunk each, never a read per pull. | |
| 28 | */ | |
| 29 | export function lastPersonAction( | |
| 30 | db: Database, | |
| 31 | pulls: readonly Issue[], | |
| 32 | ): Map<string, string> { | |
| 33 | const out = new Map( | |
| 34 | pulls.map((p) => [p.id, p.createdAt] as const), | |
| 35 | ); | |
| 36 | const ids = [...out.keys()]; | |
| 37 | for (let n = 0; n < ids.length; n += CHUNK) { | |
| 38 | const batch = ids.slice(n, n + CHUNK); | |
| 39 | const marks = batch.map((_, k) => `?${k + 1}`).join(","); | |
| 40 | for (const table of ["comment", "review"] as const) | |
| 41 | for (const row of db | |
| 42 | .query<{ id: string; at: string }, string[]>( | |
| 43 | `SELECT ${table}.issue_id AS id, MAX(${table}.created_at) AS at | |
| 44 | FROM ${table} JOIN user ON user.id = ${table}.author_id | |
| 45 | WHERE ${table}.issue_id IN (${marks}) AND user.kind = 'user' AND user.is_agent = 0 | |
| 46 | GROUP BY ${table}.issue_id`, | |
| 47 | ) | |
| 48 | .all(...batch)) | |
| 49 | if (row.at > out.get(row.id)!) out.set(row.id, row.at); | |
| 50 | } | |
| 51 | return out; | |
| 52 | } | |
| 53 | ||
| 54 | /** The newest recommendation a crew member wrote on each pull that has one. */ | |
| 55 | export function recommendationsFor( | |
| 56 | db: Database, | |
| 57 | issueIds: readonly string[], | |
| 58 | ): Map<string, Recommendation> { | |
| 59 | const out = new Map<string, Recommendation>(); | |
| 60 | for (let n = 0; n < issueIds.length; n += CHUNK) { | |
| 61 | const batch = issueIds.slice(n, n + CHUNK); | |
| 62 | const marks = batch.map((_, k) => `?${k + 1}`).join(","); | |
| 63 | for (const row of db | |
| 64 | .query<{ id: string; body: string }, string[]>( | |
| 65 | `SELECT comment.issue_id AS id, comment.body AS body | |
| 66 | FROM comment JOIN user ON user.id = comment.author_id | |
| 67 | WHERE comment.issue_id IN (${marks}) AND user.is_agent = 1 | |
| 68 | AND comment.body LIKE '%ecommendation:%' | |
| 69 | ORDER BY comment.created_at, comment.id`, | |
| 70 | ) | |
| 71 | .all(...batch)) { | |
| 72 | const m = LINE.exec(row.body); | |
| 73 | if (m) out.set(row.id, { verdict: m[1]!.toLowerCase() as Verdict, reason: m[2]!.trim().slice(0, 300) }); | |
| 74 | } | |
| 75 | } | |
| 76 | return out; | |
| 77 | } | |
| 78 | ||
| 79 | /** "waiting 30 hours" / "waiting 4 days", as a person says a wait. */ | |
| 80 | export function waitWords(ms: number): string { | |
| 81 | const hours = Math.floor(ms / 3_600_000); | |
| 82 | if (hours < 48) return `waiting ${hours} ${hours === 1 ? "hour" : "hours"}`; | |
| 83 | const days = Math.floor(hours / 24); | |
| 84 | return `waiting ${days} days`; | |
| 85 | } |
src/core/waiting_for.ts +1 −1
| @@ -18,7 +18,7 @@ export interface WaitingFor { | ||
| 18 | 18 | |
| 19 | 19 | export interface WaitFacts { |
| 20 | 20 | /** The ask's kind (`DecisionKind`). */ |
| 21 | readonly kind: "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "proposal"; | |
| 21 | readonly kind: "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "waiting" | "assigned" | "proposal"; | |
| 22 | 22 | /** Files stopping the pull from merging, as the last sweep found it. */ |
| 23 | 23 | readonly conflicts: number; |
| 24 | 24 | /** Whether the pull or issue the ask is about is assigned to the viewer. */ |
src/tui/screens/dashboard.ts +18 −5
| @@ -131,6 +131,10 @@ interface OnYouItem { | ||
| 131 | 131 | readonly hand?: true; |
| 132 | 132 | /** A pull the crew handed to a person: closed, marked and opened like any row. */ |
| 133 | 133 | readonly over?: true; |
| 134 | /** For a handed-over row: the crew's advice, when it wrote one. */ | |
| 135 | readonly overAdvice?: string; | |
| 136 | /** A pull no person has touched for a day, with the crew's advice when it wrote one. */ | |
| 137 | readonly late?: { readonly advice: string }; | |
| 134 | 138 | /** What it waits for, and on whom: the word the web's asks row shows. */ |
| 135 | 139 | readonly waiting: WaitingFor; |
| 136 | 140 | } |
| @@ -319,6 +323,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 319 | 323 | rows: ReturnType<typeof askLinesFor>, |
| 320 | 324 | waitingOf: (id: string) => WaitingFor, |
| 321 | 325 | ): OnYouItem[] => { |
| 326 | const advice = (id: string, kind: "waiting" | "handover" = "waiting"): string => { | |
| 327 | const r = rows.find((row) => row.items.includes(`${kind}:${id}`))?.recommendation; | |
| 328 | return r === undefined ? "" : ` · crew advises: ${r.verdict}${r.reason === "" ? "" : ` — ${r.reason}`}`; | |
| 329 | }; | |
| 322 | 330 | if (f.user === undefined) return []; |
| 323 | 331 | const idsOf = (kind: string, prefix: string): string[] => |
| 324 | 332 | rows |
| @@ -326,11 +334,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 326 | 334 | .flatMap((row) => row.items) |
| 327 | 335 | .filter((id) => id.startsWith(prefix)) |
| 328 | 336 | .map((id) => id.slice(prefix.length)); |
| 329 | const wanted: { id: string; flag: "hand" | "over" }[] = [ | |
| 337 | const wanted: { id: string; flag: "hand" | "over" | "late" }[] = [ | |
| 330 | 338 | ...(f.app.config.decider === undefined |
| 331 | 339 | ? [] |
| 332 | 340 | : idsOf("parked", "parked:").map((id) => ({ id, flag: "hand" as const }))), |
| 333 | 341 | ...idsOf("handover", "handover:").map((id) => ({ id, flag: "over" as const })), |
| 342 | ...idsOf("waiting", "waiting:").map((id) => ({ id, flag: "late" as const })), | |
| 334 | 343 | ]; |
| 335 | 344 | const out: OnYouItem[] = []; |
| 336 | 345 | const seen = new Set<string>(); |
| @@ -340,7 +349,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 340 | 349 | const issue = getIssueById(f.db, id); |
| 341 | 350 | const repo = issue === undefined ? undefined : getRepoById(f.db, issue.repoId); |
| 342 | 351 | if (issue !== undefined && repo !== undefined) |
| 343 | out.push({ issue, repo, waiting: waitingOf(id), ...(flag === "hand" ? { hand: true as const } : { over: true as const }) }); | |
| 352 | out.push({ issue, repo, waiting: waitingOf(id), ...(flag === "hand" | |
| 353 | ? { hand: true as const } | |
| 354 | : flag === "over" | |
| 355 | ? { over: true as const, overAdvice: advice(id, "handover") } | |
| 356 | : { late: { advice: advice(id) } }) }); | |
| 344 | 357 | } |
| 345 | 358 | return out; |
| 346 | 359 | }; |
| @@ -409,7 +422,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 409 | 422 | |
| 410 | 423 | /** One verb for the set, through the same action the web and API use. */ |
| 411 | 424 | const clear = (action: "close" | "mute" | "ship", reason?: string): void => { |
| 412 | const items = targets().map((t) => `${t.over === true ? "handover" : "issue"}:${t.issue.id}`); | |
| 425 | const items = targets().map((t) => `${t.over === true ? "handover" : t.late !== undefined ? "waiting" : "issue"}:${t.issue.id}`); | |
| 413 | 426 | if (items.length === 0) return; |
| 414 | 427 | runAsync(ctx, async () => { |
| 415 | 428 | const done = await applyAsksBulk(f.app, f.user, action, items, reason); |
| @@ -1009,8 +1022,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1009 | 1022 | ? PULL_MARK |
| 1010 | 1023 | : ISSUE_MARK; |
| 1011 | 1024 | return { |
| 1012 | title: `${marked.has(item.issue.id) ? "[x] " : ""}${item.hand === true ? "parked " : item.over === true ? "handed over " : ""}${mark} #${item.issue.number} ${item.issue.title} · ${item.repo.ownerName}/${item.repo.name}`, | |
| 1013 | meta: `${item.waiting.word} · on ${item.waiting.on}`, | |
| 1025 | title: `${marked.has(item.issue.id) ? "[x] " : ""}${item.hand === true ? "parked " : item.over === true ? "handed over " : item.late !== undefined ? "waiting " : ""}${mark} #${item.issue.number} ${item.issue.title} · ${item.repo.ownerName}/${item.repo.name}`, | |
| 1026 | meta: `${item.waiting.word} · on ${item.waiting.on}${item.late?.advice ?? item.overAdvice ?? ""}`, | |
| 1014 | 1027 | }; |
| 1015 | 1028 | }), |
| 1016 | 1029 | ...waves.map( |
src/web/studio/asks.ts +95 −3
| @@ -15,6 +15,7 @@ import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts"; | ||
| 15 | 15 | import { crewForRepos } from "../../core/agents.ts"; |
| 16 | 16 | import { appsForRepos } from "../../core/apps.ts"; |
| 17 | 17 | import { latestDecision, latestDecisionsFor, stopStands } from "../../core/decisions.ts"; |
| 18 | import { lastPersonAction, recommendationsFor, waitWords, WAIT_LIMIT_MS, type Recommendation } from "../../core/pull_wait.ts"; | |
| 18 | 19 | import { getHold, holdsFor } from "../../core/holds.ts"; |
| 19 | 20 | import { issueTitles, proposalsFor, type Proposal } from "../../core/proposals.ts"; |
| 20 | 21 | import { chipsOf, type Chips } from "../../core/feed_chips.ts"; |
| @@ -106,7 +107,7 @@ export function writableAppsOf(ctx: Ctx, user: User | undefined) { | ||
| 106 | 107 | ); |
| 107 | 108 | } |
| 108 | 109 | |
| 109 | export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "proposal"; | |
| 110 | export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "waiting" | "assigned" | "proposal"; | |
| 110 | 111 | |
| 111 | 112 | /** Dollars as a person reads them: cents when small, whole when not. */ |
| 112 | 113 | const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0)); |
| @@ -137,6 +138,8 @@ export interface Decision { | ||
| 137 | 138 | readonly consequence: Consequence; |
| 138 | 139 | /** What it waits for, and on whom (`core/waiting_for.ts`). */ |
| 139 | 140 | readonly waiting: WaitingFor; |
| 141 | readonly why?: string | undefined; | |
| 142 | readonly recommendation?: Recommendation | undefined; | |
| 140 | 143 | } |
| 141 | 144 | |
| 142 | 145 | /** What an ask asks, the answers it takes, and what happens if nobody gives one. */ |
| @@ -158,6 +161,11 @@ export const ASK_WORDS: Readonly<Record<DecisionKind, AskWords>> = { | ||
| 158 | 161 | options: ["Ship", "Say what is off"], |
| 159 | 162 | ifNothing: "it waits; an agent's change never ships unreviewed", |
| 160 | 163 | }, |
| 164 | waiting: { | |
| 165 | decision: "No one has looked at this in a day: merge it, close it, or say what to change?", | |
| 166 | options: ["Ship", "Say what to change", "Close it"], | |
| 167 | ifNothing: "it keeps waiting for you", | |
| 168 | }, | |
| 161 | 169 | stopped: { |
| 162 | 170 | decision: "The check fails and the crew stopped: what should it try?", |
| 163 | 171 | options: ["Tell them what to try", "Close it"], |
| @@ -213,6 +221,7 @@ export const KIND_TIER: Readonly<Record<DecisionKind, Consequence>> = { | ||
| 213 | 221 | declined: { score: 70, why: "the planner has stopped for this app" }, |
| 214 | 222 | stopped: { score: 50, why: "red, and the crew stopped" }, |
| 215 | 223 | review: { score: 45, why: "an agent's change waits for a review" }, |
| 224 | waiting: { score: 42, why: "no person has acted on it for a day" }, | |
| 216 | 225 | handover: { score: 55, why: "three reviews without an approval: handed to a person" }, |
| 217 | 226 | parked: { score: 40, why: "parked by the crew" }, |
| 218 | 227 | look: { score: 30, why: "green, waiting" }, |
| @@ -279,8 +288,16 @@ interface AskCandidate { | ||
| 279 | 288 | readonly decline?: { readonly runId: string; readonly words: string }; |
| 280 | 289 | /** For "handover": the reviews that asked for changes. */ |
| 281 | 290 | readonly handover?: Handover; |
| 291 | /** For "handover" and "waiting": one sentence on why a person must decide. */ | |
| 292 | readonly why?: string; | |
| 293 | /** For "handover" and "waiting": what the crew advised, when it wrote one. */ | |
| 294 | readonly recommendation?: Recommendation; | |
| 282 | 295 | } |
| 283 | 296 | |
| 297 | /** How a handed-over pull says why: the rounds, and the hand-over. */ | |
| 298 | const THREE_ROUNDS = "three reviews asked for changes"; | |
| 299 | const HANDOVER_WHY = `${THREE_ROUNDS} with no approval between; the crew handed it to a person`; | |
| 300 | ||
| 284 | 301 | /** |
| 285 | 302 | * Open pulls the viewer may land, sorted into what asks them: stopped (red |
| 286 | 303 | * past the crew's own limit since a person last spoke — the crew's own |
| @@ -352,6 +369,7 @@ function askCandidatesFor( | ||
| 352 | 369 | new Date(), |
| 353 | 370 | handedOver, |
| 354 | 371 | ); |
| 372 | const first = out.length; | |
| 355 | 373 | for (const issue of pulls) { |
| 356 | 374 | const repo = byId.get(issue.repoId)!; |
| 357 | 375 | const pull = joined.get(issue.id); |
| @@ -381,6 +399,7 @@ function askCandidatesFor( | ||
| 381 | 399 | who, |
| 382 | 400 | authorIsAgent: issue.authorIsAgent, |
| 383 | 401 | handover: over, |
| 402 | why: HANDOVER_WHY, | |
| 384 | 403 | }); |
| 385 | 404 | continue; |
| 386 | 405 | } |
| @@ -463,6 +482,38 @@ function askCandidatesFor( | ||
| 463 | 482 | }); |
| 464 | 483 | } |
| 465 | 484 | } |
| 485 | // Whatever else a pull is, one no person has touched for a day is theirs | |
| 486 | // to decide, whatever its check says. The crew's own pull still at work | |
| 487 | // never reaches here; nor does one younger than a day. | |
| 488 | const asked = new Set(out.slice(first).map((a) => a.pull?.id)); | |
| 489 | const idle = pulls.filter( | |
| 490 | (p) => joined.has(p.id) && !asked.has(p.id) && held.get(p.id)?.heldBy !== user.id && !working.has(p.id), | |
| 491 | ); | |
| 492 | const acted = lastPersonAction(db, idle); | |
| 493 | const nowMs = Date.now(); | |
| 494 | const stale = idle.filter((p) => nowMs - Date.parse(acted.get(p.id)!) > WAIT_LIMIT_MS); | |
| 495 | const advice = recommendationsFor(db, [...stale.map((p) => p.id), ...out.slice(first).filter((a) => a.kind === "handover").map((a) => a.pull!.id)]); | |
| 496 | for (const issue of stale) { | |
| 497 | const since = acted.get(issue.id)!; | |
| 498 | const rounds = handedOver.has(issue.id) ? `, ${THREE_ROUNDS}` : ""; | |
| 499 | const rec = advice.get(issue.id); | |
| 500 | out.push({ | |
| 501 | kind: "waiting", | |
| 502 | id: `waiting:${issue.id}`, | |
| 503 | since, | |
| 504 | repo: byId.get(issue.repoId)!, | |
| 505 | pull: joined.get(issue.id)!, | |
| 506 | who: issue.authorName, | |
| 507 | authorIsAgent: issue.authorIsAgent, | |
| 508 | why: `${waitWords(nowMs - Date.parse(since))}${rounds}`, | |
| 509 | ...(rec === undefined ? {} : { recommendation: rec }), | |
| 510 | }); | |
| 511 | } | |
| 512 | for (let i = first; i < out.length; i++) { | |
| 513 | const a = out[i]!; | |
| 514 | const rec = a.kind === "handover" ? advice.get(a.pull!.id) : undefined; | |
| 515 | if (rec !== undefined) out[i] = { ...a, recommendation: rec }; | |
| 516 | } | |
| 466 | 517 | const crew = crewForRepos( |
| 467 | 518 | db, |
| 468 | 519 | repos.map((r) => r.id), |
| @@ -644,7 +695,7 @@ function consequencesOf( | ||
| 644 | 695 | /** The row an ask collapses into: one kind in one repository. */ |
| 645 | 696 | const groupId = (a: { kind: DecisionKind; repo: Repo; id: string }): string => |
| 646 | 697 | // Work assigned to you is each its own row: they share no decision. |
| 647 | a.kind === "assigned" || a.kind === "proposal" ? a.id : `${a.kind}:${a.repo.id}`; | |
| 698 | a.kind === "assigned" || a.kind === "waiting" || a.kind === "proposal" ? a.id : `${a.kind}:${a.repo.id}`; | |
| 648 | 699 | |
| 649 | 700 | /** |
| 650 | 701 | * The asks the viewer has not set aside: a muted repository's asks are all |
| @@ -818,7 +869,7 @@ export function endOf( | ||
| 818 | 869 | raisedAt: string, |
| 819 | 870 | ): Exclude<AskEnd, "dismissed"> { |
| 820 | 871 | const [kind, what] = askId.split(":") as [DecisionKind, string]; |
| 821 | if (kind === "look" || kind === "review" || kind === "stopped" || kind === "handover") { | |
| 872 | if (kind === "look" || kind === "review" || kind === "stopped" || kind === "handover" || kind === "waiting") { | |
| 822 | 873 | const pull = getIssueById(db, what); |
| 823 | 874 | // Shipped or closed: someone decided. Held by the one it asked: they |
| 824 | 875 | // answered "not yet". Still open otherwise: the cause went away. |
| @@ -904,6 +955,7 @@ function lineOf(c: AskCandidate): string { | ||
| 904 | 955 | return `proposes "${c.proposal?.title ?? ""}" and waits for your nod`; |
| 905 | 956 | if (c.kind === "declined") |
| 906 | 957 | return "the planner declined the direction and waits for you"; |
| 958 | if (c.kind === "waiting") return `"${c.pull?.title ?? ""}" is ${c.why ?? "waiting"} for a person`; | |
| 907 | 959 | if (c.kind === "handover") |
| 908 | 960 | return `"${c.pull?.title ?? ""}" had three reviews asking for changes and waits for you`; |
| 909 | 961 | const title = c.pull?.title ?? ""; |
| @@ -931,6 +983,7 @@ export const ROW_PLURAL: Readonly<Record<DecisionKind, (n: number) => string>> = | ||
| 931 | 983 | stopped: (n) => `the crew stopped on ${n} changes`, |
| 932 | 984 | parked: (n) => `the crew parked ${n} issues after three failed runs`, |
| 933 | 985 | handover: (n) => `${n} changes had three reviews asking for changes and wait for you`, |
| 986 | waiting: (n) => `${n} changes have waited a day for a person`, | |
| 934 | 987 | declined: (n) => `the planner declined ${n} times and waits for you`, |
| 935 | 988 | proposal: (n) => `${n} waves wait for your nod`, |
| 936 | 989 | paused: (n) => `${n} pauses at the ceiling`, |
| @@ -974,6 +1027,10 @@ export function askLinesFor( | ||
| 974 | 1027 | consequence: Consequence; |
| 975 | 1028 | /** What the row waits for (the weightiest ask's), and on whom. */ |
| 976 | 1029 | waitingFor: WaitingFor; |
| 1030 | /** For a row of one handed-over or long-waiting pull: why a person must decide. */ | |
| 1031 | why?: string; | |
| 1032 | /** The crew's advice on it, when it wrote one. */ | |
| 1033 | recommendation?: Recommendation; | |
| 977 | 1034 | }[] { |
| 978 | 1035 | const repos = listReposVisibleTo(db, user, { limit: 200, withoutArchived: true }); |
| 979 | 1036 | return askRows(waitingOn(db, user, repos, chips)).map((row) => { |
| @@ -989,6 +1046,8 @@ export function askLinesFor( | ||
| 989 | 1046 | waitingFor: row.items[0]!.waiting, |
| 990 | 1047 | }; |
| 991 | 1048 | const only = row.items.length === 1 ? row.items[0]! : undefined; |
| 1049 | if (only?.why !== undefined) Object.assign(about, { why: only.why }); | |
| 1050 | if (only?.recommendation !== undefined) Object.assign(about, { recommendation: only.recommendation }); | |
| 992 | 1051 | if (only === undefined) |
| 993 | 1052 | return { |
| 994 | 1053 | ...about, |
| @@ -1113,6 +1172,20 @@ export function decisionsFor( | ||
| 1113 | 1172 | who: c.who, |
| 1114 | 1173 | says: `The reviewer asked for changes three times with no approval between, so the crew stopped. ${handoverLines(c.handover).join(" ")} Say what to try (or approve it on its page), or close it.`, |
| 1115 | 1174 | short: `"${c.pull.title}" had three reviews asking for changes`, |
| 1175 | why: c.why, | |
| 1176 | recommendation: c.recommendation, | |
| 1177 | }; | |
| 1178 | if (c.kind === "waiting" && c.pull) | |
| 1179 | return { | |
| 1180 | kind: "waiting", | |
| 1181 | id: `waiting:${c.pull.id}`, | |
| 1182 | repo: c.repo, | |
| 1183 | pull: c.pull, | |
| 1184 | who: c.who, | |
| 1185 | says: `"${c.pull.title}" is ${c.why ?? "waiting"}, and no person has acted on it. Ship it, say what to change, or close it.`, | |
| 1186 | short: c.why ?? "waiting for a person", | |
| 1187 | why: c.why, | |
| 1188 | recommendation: c.recommendation, | |
| 1116 | 1189 | }; |
| 1117 | 1190 | if (c.kind === "stopped" && c.pull) { |
| 1118 | 1191 | const tries = c.tries ?? 0; |
| @@ -1209,6 +1282,13 @@ export function decisionForm(d: Decision, back?: string): Html | null { | ||
| 1209 | 1282 | return html`<form ${own} action="${base}/pulls/${pull.number}/ship"> |
| 1210 | 1283 | <input type="hidden" name="pull" value="${pull.number}" />${where} |
| 1211 | 1284 | </form>`; |
| 1285 | if (d.kind === "waiting") | |
| 1286 | return html`<form ${own} action="${base}/pulls/${pull.number}/ship"> | |
| 1287 | <input type="hidden" name="pull" value="${pull.number}" />${where} | |
| 1288 | </form> | |
| 1289 | <form id="${askFormId(d)}-close" method="post" hidden action="${base}/pulls/${pull.number}/state"> | |
| 1290 | <input type="hidden" name="state" value="closed" />${where} | |
| 1291 | </form>`; | |
| 1212 | 1292 | return null; |
| 1213 | 1293 | } |
| 1214 | 1294 | |
| @@ -1251,6 +1331,17 @@ export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others: | ||
| 1251 | 1331 | }), |
| 1252 | 1332 | ], |
| 1253 | 1333 | }; |
| 1334 | if (d.kind === "waiting" && pull) | |
| 1335 | return { | |
| 1336 | primary: btn("Ship", { size: "sm", form: askFormId(d), variant: "primary" }), | |
| 1337 | others: [ | |
| 1338 | btn("Say what to change", { | |
| 1339 | href: `${changeHref(base, pull.number)}#comment-body`, | |
| 1340 | size: "sm", | |
| 1341 | }), | |
| 1342 | btn("Close", { size: "sm", form: `${askFormId(d)}-close` }), | |
| 1343 | ], | |
| 1344 | }; | |
| 1254 | 1345 | if (d.kind === "assigned" && (d.pull ?? d.issue)) |
| 1255 | 1346 | return { |
| 1256 | 1347 | primary: btn("Open", { |
| @@ -1352,6 +1443,7 @@ export function askItemsOf(issue: Issue): string[] { | ||
| 1352 | 1443 | `ask:review:${issue.id}`, |
| 1353 | 1444 | `ask:stopped:${issue.id}`, |
| 1354 | 1445 | `ask:handover:${issue.id}`, |
| 1446 | `ask:waiting:${issue.id}`, | |
| 1355 | 1447 | `ask:assigned:${issue.id}`, |
| 1356 | 1448 | ] |
| 1357 | 1449 | : [`ask:parked:${issue.id}`, `ask:assigned:${issue.id}`]; |
src/web/studio/asks_page.ts +11 −1
| @@ -69,10 +69,12 @@ const ASK_KIND_WORDS: Readonly<Record<string, string>> = { | ||
| 69 | 69 | stopped: "Stopped", |
| 70 | 70 | parked: "Parked", |
| 71 | 71 | assigned: "Assigned", |
| 72 | waiting: "Waiting", | |
| 73 | handover: "Handed over", | |
| 72 | 74 | }; |
| 73 | 75 | |
| 74 | 76 | /** The kinds a bulk action takes: a ceiling and a planner's decline are decided where they live. */ |
| 75 | const BULK_KINDS: ReadonlySet<Decision["kind"]> = new Set(["look", "review", "stopped", "handover", "parked", "assigned"]); | |
| 77 | const BULK_KINDS: ReadonlySet<Decision["kind"]> = new Set(["look", "review", "stopped", "handover", "waiting", "parked", "assigned"]); | |
| 76 | 78 | |
| 77 | 79 | /** A row's box for the bulk form, which sits apart (`form="asks-bulk"`): forms do not nest. */ |
| 78 | 80 | const pick = (d: Decision, picked: ReadonlySet<string>): Html | null => |
| @@ -172,6 +174,7 @@ const KIND_LABEL: Readonly<Record<Decision["kind"], string>> = { | ||
| 172 | 174 | review: "Needs review", |
| 173 | 175 | stopped: "Stopped", |
| 174 | 176 | handover: "Handed over", |
| 177 | waiting: "Waiting", | |
| 175 | 178 | parked: "Parked", |
| 176 | 179 | declined: "Declined", |
| 177 | 180 | proposal: "Proposed", |
| @@ -197,6 +200,12 @@ function acts(ctx: Ctx, d: Decision): Html { | ||
| 197 | 200 | </div>`; |
| 198 | 201 | } |
| 199 | 202 | |
| 203 | /** What the crew advised on a pull waiting for a person, in one plain line; nothing when it wrote none. */ | |
| 204 | const adviceLine = (d: Decision): Html | null => | |
| 205 | d.recommendation === undefined | |
| 206 | ? null | |
| 207 | : html`<p class="small muted">The crew advises: ${d.recommendation.verdict}${d.recommendation.reason === "" ? "" : html` — ${d.recommendation.reason}`}</p>`; | |
Uses the .small and .muted utilities, so it fits the design system. If the advice becomes a recurring part of rows, give it a named component rather than an inline paragraph. |
||
| 208 | ||
| 200 | 209 | /** A wave proposal's why, its issue titles and the Redirect box; nothing for any other ask. */ |
| 201 | 210 | function proposalBody(ctx: Ctx, d: Decision): Html | null { |
| 202 | 211 | if (!d.proposal) return null; |
| @@ -223,6 +232,7 @@ function decisionCard(ctx: Ctx, d: Decision, picked: ReadonlySet<string>): Html | ||
| 223 | 232 | : html`<a href="${base}/settings/autonomy">Paused</a>`; |
| 224 | 233 | return card( |
| 225 | 234 | html`${clause(d, d.kind, d.consequence)} |
| 235 | ${adviceLine(d)} | |
| 226 | 236 | ${proposalBody(ctx, d)} |
| 227 | 237 | ${acts(ctx, d)}`, |
| 228 | 238 | { |
test/asks-waiting.test.ts added +278
| @@ -0,0 +1,278 @@ | ||
| 1 | /** | |
| 2 | * The asks list holds every pull waiting on a person (#3256): one handed | |
| 3 | * over, or untouched by a person for a day whatever its check says. A crew | |
| 4 | * pull still young stays the crew's. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; | |
| 13 | import { addComment } from "../src/core/issues.ts"; | |
| 14 | import { setHold } from "../src/core/holds.ts"; | |
| 15 | import { createRepo, type Repo } from "../src/core/repos.ts"; | |
| 16 | import { submitReview } from "../src/core/reviews.ts"; | |
| 17 | import { createToken } from "../src/core/tokens.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 20 | import { id } from "../src/kernel/id.ts"; | |
| 21 | import { askLinesFor } from "../src/web/studio/asks.ts"; | |
| 22 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 23 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 24 | import { settleApp } from "./helpers/tui.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | const ZERO = "0".repeat(40); | |
| 30 | const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString(); | |
| 31 | ||
| 32 | interface Row { | |
| 33 | id: string; | |
| 34 | kind: string; | |
| 35 | why?: string; | |
| 36 | recommendation?: { verdict: string; reason: string }; | |
| 37 | } | |
| 38 | ||
| 39 | describe("asks that wait on a person", () => { | |
| 40 | let dataDir = ""; | |
| 41 | let app: App; | |
| 42 | let servers: Servers; | |
| 43 | let owner: User; | |
| 44 | let builder: User; | |
| 45 | let reviewer: User; | |
| 46 | let repo: Repo; | |
| 47 | let token = ""; | |
| 48 | let seq = 0; | |
| 49 | ||
| 50 | const user = async (username: string, agent: boolean) => { | |
| 51 | const made = await createUser(app.db, { | |
| 52 | username, | |
| 53 | email: `${username}@forge.test`, | |
| 54 | ...(agent | |
| 55 | ? { agent: true, origin: "agent" as const } | |
| 56 | : { password: "a-good-passphrase", origin: "seed" as const, trusted: true, adminIfFirst: true }), | |
| 57 | }); | |
| 58 | if (!made.ok) throw new Error(`seed ${username}: ${made.error}`); | |
| 59 | if (agent) createAgentRow(app.db, { userId: made.value.id, brief: `${username}.` }); | |
| 60 | return made.value; | |
| 61 | }; | |
| 62 | ||
| 63 | const pull = (author: User, title: string, age: number) => { | |
| 64 | const issueId = id(); | |
| 65 | const number = app.db | |
| 66 | .query<{ issue_seq: number }, [string]>( | |
| 67 | "UPDATE repo SET issue_seq = issue_seq + 1 WHERE id = ?1 RETURNING issue_seq", | |
| 68 | ) | |
| 69 | .get(repo.id)!.issue_seq; | |
| 70 | app.db | |
| 71 | .query( | |
| 72 | `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) | |
| 73 | VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, 'open', ?6)`, | |
| 74 | ) | |
| 75 | .run(issueId, repo.id, number, title, author.id, hoursAgo(age)); | |
| 76 | app.db | |
| 77 | .query( | |
| 78 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) | |
| 79 | VALUES (?1, 'main', ?2, ?3, ?3, ?4)`, | |
| 80 | ) | |
| 81 | .run(issueId, `b/${++seq}`, ZERO, repo.id); | |
| 82 | return issueId; | |
| 83 | }; | |
| 84 | ||
| 85 | const rows = async (): Promise<Row[]> => { | |
| 86 | const res = await fetch(`${servers.url}/api/v1/asks`, { | |
| 87 | headers: { authorization: `Bearer ${token}` }, | |
| 88 | }); | |
| 89 | return ((await res.json()) as { rows: Row[] }).rows; | |
| 90 | }; | |
| 91 | ||
| 92 | let handed = ""; | |
| 93 | let old = ""; | |
| 94 | let young = ""; | |
| 95 | ||
| 96 | beforeAll(async () => { | |
| 97 | dataDir = await mkdtemp(join(tmpdir(), "forge-asks-waiting-")); | |
| 98 | app = createApp({ dataDir, port: 0 }); | |
| 99 | servers = await serve(app); | |
| 100 | owner = await user("alice", false); | |
| 101 | builder = await user("builder", true); | |
| 102 | reviewer = await user("reviewer", true); | |
| 103 | const made = await createRepo(app.db, app.reposRoot, { | |
| 104 | ownerId: owner.id, | |
| 105 | name: "wait", | |
| 106 | visibility: "public", | |
| 107 | description: "wait", | |
| 108 | }); | |
| 109 | if (!made.ok) throw new Error(`seed repo: ${made.error}`); | |
| 110 | repo = made.value; | |
| 111 | for (const agent of [builder, reviewer]) addCrew(app.db, repo.id, agent.id, owner.id); | |
| 112 | setCrewRole(app.db, repo.id, reviewer.id, "reviewer"); | |
| 113 | token = createToken(app.db, owner.id, "ro", "ro").token; | |
| 114 | ||
| 115 | handed = pull(builder, "Handed over", 3); | |
| 116 | for (let r = 0; r < 3; r++) | |
| 117 | submitReview(app.db, { | |
| 118 | issueId: handed, | |
| 119 | authorId: reviewer.id, | |
| 120 | state: "changes_requested", | |
| 121 | body: `Round ${r}.`, | |
| 122 | commitOid: ZERO, | |
| 123 | }); | |
| 124 | old = pull(owner, "Thirty hours", 30); | |
| 125 | young = pull(builder, "Two hours", 2); | |
| 126 | }, 30_000); | |
| 127 | ||
| 128 | afterAll(async () => { | |
| 129 | await servers.stop(); | |
| 130 | await rm(dataDir, { recursive: true, force: true }); | |
| 131 | }); | |
| 132 | ||
| 133 | test("lists the handed-over pull and the 30 h pull, each with its why, and not the young crew pull", async () => { | |
| 134 | const got = await rows(); | |
| 135 | expect(got.map((r) => r.id).sort()).toEqual([`handover:${handed}`, `waiting:${old}`].sort()); | |
| 136 | const byKind = new Map(got.map((r) => [r.kind, r])); | |
| 137 | expect(byKind.get("handover")!.why).toContain("three reviews asked for changes"); | |
| 138 | expect(byKind.get("waiting")!.why).toBe("waiting 30 hours"); | |
| 139 | expect(got.some((r) => r.id.endsWith(young))).toBe(false); | |
| 140 | }); | |
| 141 | ||
| 142 | test("a handed-over row carries the crew's recommendation too", async () => { | |
| 143 | addComment(app.db, { | |
| 144 | issueId: handed, | |
| 145 | authorId: reviewer.id, | |
| 146 | body: "Recommendation: close — three rounds and no progress.", | |
| 147 | }); | |
| 148 | const row = (await rows()).find((r) => r.id === `handover:${handed}`)!; | |
| 149 | expect(row.recommendation).toEqual({ verdict: "close", reason: "three rounds and no progress." }); | |
| 150 | }); | |
| 151 | ||
| 152 | test("a pull the viewer holds never asks again for being idle", async () => { | |
| 153 | const held = pull(builder, "Held aside", 50); | |
| 154 | expect((await rows()).some((r) => r.id === `waiting:${held}`)).toBe(true); | |
| 155 | setHold(app.db, held, owner.id, "later"); | |
| 156 | expect((await rows()).some((r) => r.id === `waiting:${held}`)).toBe(false); | |
| 157 | }); | |
| 158 | ||
| 159 | test("/asks shows the Waiting row, its advice, and a pick box to ship or close it with the rest", async () => { | |
| 160 | const adv = pull(builder, "Advised idle", 40); | |
| 161 | addComment(app.db, { issueId: adv, authorId: reviewer.id, body: "Recommendation: change — rename the flag <b>." }); | |
| 162 | const signed = await fetch(`${servers.url}/signin`, { | |
| 163 | method: "POST", | |
| 164 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 165 | redirect: "manual", | |
| 166 | }); | |
| 167 | const cookie = (signed.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 168 | const page = await ( | |
| 169 | await fetch(`${servers.url}/asks`, { headers: { cookie: `${cookie}; forge_mode=studio` } }) | |
| 170 | ).text(); | |
| 171 | expect(page).toContain("Waiting"); | |
| 172 | expect(page).toContain("Advised idle"); | |
| 173 | expect(page).toContain("The crew advises: change"); | |
| 174 | expect(page).toContain("rename the flag <b>."); | |
| 175 | expect(page).not.toContain("flag <b>"); | |
| 176 | // The row can be acted on where it stands: ship and close forms, a word, no Settings dead end. | |
| 177 | const row = page.slice(page.indexOf("Advised idle") - 1500, page.indexOf("Advised idle") + 2500); | |
| 178 | expect(page).toMatch(/<form id="ask-waiting:[^"]+" method="post" hidden action="[^"]*\/pulls\/\d+\/ship">/); | |
| 179 | expect(page).toMatch(/<form id="ask-waiting:[^"]+-close" method="post" hidden action="[^"]*\/pulls\/\d+\/state">/); | |
| 180 | expect(page).toContain("Say what to change"); | |
| 181 | expect(row).not.toContain("/settings/autonomy"); | |
| 182 | expect(page).toContain(">Handed over<"); | |
| 183 | expect(page).toMatch(/class="ask-pick"[^>]*value="waiting:[^"]+"[^>]*data-kind="waiting"/); | |
| 184 | }); | |
| 185 | ||
| 186 | test("the MCP asks tool carries the waiting kind, its why and the recommendation", async () => { | |
| 187 | const res = await fetch(`${servers.url}/mcp`, { | |
| 188 | method: "POST", | |
| 189 | headers: { | |
| 190 | "content-type": "application/json", | |
| 191 | accept: "application/json, text/event-stream", | |
| 192 | authorization: `Bearer ${token}`, | |
| 193 | }, | |
| 194 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "asks", arguments: {} } }), | |
| 195 | }); | |
| 196 | const body = (await res.json()) as { result: { content: { text: string }[] } }; | |
| 197 | const got = JSON.parse(body.result.content[0]!.text) as { rows: Row[] }; | |
| 198 | const waiting = got.rows.filter((r) => r.kind === "waiting"); | |
| 199 | expect(waiting.length).toBeGreaterThan(0); | |
| 200 | expect(waiting.every((r) => r.why?.startsWith("waiting "))).toBe(true); | |
| 201 | expect(waiting.some((r) => r.recommendation?.verdict === "change")).toBe(true); | |
| 202 | }); | |
| 203 | ||
| 204 | test("the terminal lists the waiting pull with its advice, for its viewer only", async () => { | |
| 205 | const lines = askLinesFor(app.db, owner); | |
| 206 | expect(lines.some((r) => r.kind === "waiting")).toBe(true); | |
| 207 | const sees = async (user: User) => { | |
| 208 | const terminal = headlessTerminal(240, 40); | |
| 209 | const { app: ui } = createForgeApp( | |
| 210 | app, | |
| 211 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user }, | |
| 212 | { terminal, exitHooks: false }, | |
| 213 | ); | |
| 214 | ui.start(); | |
| 215 | await settleApp(ui); | |
| 216 | for (const key of ["tab", "j"]) { | |
| 217 | ui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 218 | await settleApp(ui); | |
| 219 | } | |
| 220 | const text = ui.screen.toText(); | |
| 221 | ui.stop(); | |
| 222 | return text; | |
| 223 | }; | |
| 224 | const mine = await sees(owner); | |
| 225 | expect(mine).toContain("waiting "); | |
| 226 | expect(mine).toContain("crew advises: change"); | |
| 227 | expect(await sees(builder)).not.toContain("crew advises"); | |
| 228 | }); | |
| 229 | ||
| 230 | test("the terminal's handed-over row says what the crew advised", async () => { | |
| 231 | const terminal = headlessTerminal(240, 60); | |
| 232 | const { app: ui } = createForgeApp( | |
| 233 | app, | |
| 234 | { sshUsername: owner.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: owner }, | |
| 235 | { terminal, exitHooks: false }, | |
| 236 | ); | |
| 237 | ui.start(); | |
| 238 | await settleApp(ui); | |
| 239 | const text = ui.screen.toText(); | |
| 240 | ui.stop(); | |
| 241 | expect(text).toContain("handed over"); | |
| 242 | expect(text).toContain("crew advises: close — three rounds and no progress."); | |
| 243 | }); | |
| 244 | ||
| 245 | test("a person's word restarts the clock; the crew's recommendation rides along", async () => { | |
| 246 | addComment(app.db, { | |
| 247 | issueId: old, | |
| 248 | authorId: reviewer.id, | |
| 249 | body: "Recommendation: merge — the diff is small and green.", | |
| 250 | }); | |
| 251 | const advised = (await rows()).find((r) => r.id === `waiting:${old}`)!; | |
| 252 | expect(advised.recommendation).toEqual({ verdict: "merge", reason: "the diff is small and green." }); | |
| 253 | ||
| 254 | const said = addComment(app.db, { issueId: old, authorId: owner.id, body: "Looking." }); | |
| 255 | if (!said.ok) throw new Error(said.error); | |
| 256 | expect((await rows()).some((r) => r.id === `waiting:${old}`)).toBe(false); | |
| 257 | }); | |
| 258 | ||
| 259 | test("bulk close and bulk ship take a waiting:<id> item through the same checks", async () => { | |
| 260 | const rw = createToken(app.db, owner.id, "rw", "rw").token; | |
| 261 | const post = async (action: string, items: string[]) => { | |
| 262 | const res = await fetch(`${servers.url}/api/v1/asks/bulk`, { | |
| 263 | method: "POST", | |
| 264 | headers: { authorization: `Bearer ${rw}`, "content-type": "application/json" }, | |
| 265 | body: JSON.stringify({ action, items, preview: false }), | |
| 266 | }); | |
| 267 | return (await res.json()) as { rows: { outcome: string; reason?: string }[]; skipped: unknown[] }; | |
| 268 | }; | |
| 269 | const a = pull(builder, "Bulk close me", 60); | |
| 270 | const closed = await post("close", [`waiting:${a}`]); | |
| 271 | expect(closed.rows.map((r) => r.outcome)).toEqual(["ok"]); | |
| 272 | expect((await rows()).some((r) => r.id === `waiting:${a}`)).toBe(false); | |
| 273 | const b = pull(builder, "Bulk ship me", 60); | |
| 274 | const shipped = await post("ship", [`waiting:${b}`]); | |
| 275 | expect(shipped.skipped).toEqual([]); | |
| 276 | expect(shipped.rows).toHaveLength(1); | |
| 277 | }); | |
| 278 | }); |
new above
offline
The advice is appended only when item.late is set. Handed-over rows (item.over) carry a recommendation in askLinesFor too, but it is dropped here, which contradicts the README. Compute the advice for over rows as well and cover it in test/asks-waiting.test.ts.