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
32 changes: 22 additions & 10 deletions plugins/stewards/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1646,19 +1646,31 @@ the board calls it unfiltered, the sidebar calls it with `actionableOnly`, and
need is on the page but not in the sidebar's items.

**Realtime, not the poll.** The store takes an optional
`onNeedsYouChange(projectId)` callback, wired in `server.ts` to
`onProjectChange(projectId)` callback, wired in `server.ts` to
`bb.realtime.publish("needs-you-changed", { projectId })`; the store stays
free of bb. The callback fires from the store's mutation points — a question
filed (`ask`) or answered (`answer`, `answerQuestionOnly`, `answerEscalation`),
an escalation filed route-`me` (`escalate`) or forwarded to the user or
resolved (`reroute`, `resolve`, `approve`, `decline`), a need filed, updated or
closed (`fileNeed`, `closeNeed`, `closeNeeds`, `closeNeedByKey`), and a binding
ended or reopened (`patch`) — so a steward's own tools publish as well as the
page's buttons. A synchronous coalescing guard makes one question filed or
free of bb. The channel string is unchanged from the narrower "needs you"
signal (BB-90), so a server and client built from different images keep
talking across a restart. `patch` compares the binding before and after and
fires when a sidebar/board-visible column changed (`stage`, `phase`, `hold`,
`hold_behind`, `status`, `pending`, `requeue_at`, `advanced_at`,
`deployments`, `title`, `url`, `ending`, `ended_at`, `thread_id`), staying
silent for per-turn bookkeeping (`lastJudgmentAt`, `lastSnapshot`, `wokeAt`,
`awaitingReport`, `missedReports`, `archiveWhenIdle`, `manualDetach`, the
model-check flags) and same-value writes; `addWorker` and `setWorker` fire
too. `note()` writes its queued-notes column without a signal — a note is a
reason for the next judgment turn, and its one visible effect, the "worker
finished" phrase, is published by `setWorker` when the worker's status
changes. The callback also fires from the needs-you mutation points — a
question filed (`ask`) or answered (`answer`, `answerQuestionOnly`,
`answerEscalation`), an escalation filed route-`me` (`escalate`) or forwarded
to the user or resolved (`reroute`, `resolve`, `approve`, `decline`), and a
need filed, updated or closed (`fileNeed`, `closeNeed`, `closeNeeds`,
`closeNeedByKey`). A synchronous coalescing guard makes one question filed or
answered publish exactly one event even when the operation nests (`answer`
resolves its escalation). Every open sidebar refetches when the event names a
project it shows, and the board page refetches when it names its project; the
half-minute poll remains the fallback.
project it shows, the board page refetches when it names its project, and the
overview (which covers every project) refetches on any event; the half-minute
poll remains the fallback.

**The default provider, checked (BB-44 criterion 1).** With
`sidebar.threadListProvider` unset it is `__automatic__`, which bb resolves to
Expand Down
12 changes: 7 additions & 5 deletions plugins/stewards/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,11 +448,13 @@ server's refusal is shown in the row, and the row refetches on success. The item
carries the binding's entity key as `bindingKey`, so a row owns its item; a
project-level question with no binding has no row and stays on the Stewards
page. The plugin publishes `needs-you-changed { projectId }` on its realtime
channel whenever a question is opened or answered, an escalation is filed with
or forwarded to the user or resolved, a need is filed, updated or closed, or a
binding ends or reopens — including from a steward's own tools — so every open
sidebar and the board page refresh within seconds. The half-minute poll stays
as the fallback.
channel whenever a sidebar- or board-visible binding state changes: a question
opened or answered, an escalation filed with or forwarded to the user or
resolved, a need filed, updated or closed, and a stage, phase, hold, status
report, requeue time, deployment state, title, link, ending or worker starting
or finishing — including from a steward's own tools — so every open sidebar,
the board page and the overview refresh within seconds. The half-minute poll
stays as the fallback.

**The footer toggle.** A `ShieldCheck` icon in the sidebar footer (next to
Settings) opens a small panel above the footer row with a **Stewards
Expand Down
32 changes: 27 additions & 5 deletions plugins/stewards/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, waitFor } from "@testing-library/react";
import { loadPluginApp, renderSlot, type CapturedPluginApp, type RenderedSlot } from "@get-bb/plugin-sdk/testing/app";
import { NEEDS_YOU_CHANGED } from "./channels.js";
import { PROJECT_CHANGED } from "./channels.js";

type Need = {
id: string;
Expand Down Expand Up @@ -478,10 +478,10 @@ describe("the stewards sidebar's picker counts and refresh (BB-46)", () => {
},
});
await waitFor(() => expect(calls).toBe(1));
await slot.emitRealtime(NEEDS_YOU_CHANGED, { projectId: "p9" });
await slot.emitRealtime(PROJECT_CHANGED, { projectId: "p9" });
await new Promise((r) => setTimeout(r, 20));
expect(calls).toBe(1);
await slot.emitRealtime(NEEDS_YOU_CHANGED, { projectId: "p1" });
await slot.emitRealtime(PROJECT_CHANGED, { projectId: "p1" });
await waitFor(() => expect(calls).toBe(2));
slot.lifecycle.unmount();
});
Expand All @@ -504,15 +504,37 @@ describe("the stewards sidebar's picker counts and refresh (BB-46)", () => {
await waitFor(() => expect(calls).toBe(1));
// The handler only refetches once the board (and its project id) has rendered.
await waitFor(() => expect(slot.getByText("Acme")).toBeTruthy());
await slot.emitRealtime(NEEDS_YOU_CHANGED, { projectId: "p1" });
await slot.emitRealtime(PROJECT_CHANGED, { projectId: "p1" });
await waitFor(() => expect(calls).toBe(2));
await slot.emitRealtime(NEEDS_YOU_CHANGED, { projectId: "p2" });
await slot.emitRealtime(PROJECT_CHANGED, { projectId: "p2" });
await new Promise((r) => setTimeout(r, 20));
expect(calls).toBe(2);
slot.lifecycle.unmount();
});
});

// BB-90: the overview (Settings → Plugins → Stewards → Status) polled only, so
// it now subscribes to the widened channel too.
describe("the stewards overview's refresh (BB-90)", () => {
it("refetches on a realtime event for any project", async () => {
const captured = await loadApp();
const section = captured.settingsSections[0]!;
let calls = 0;
const slot = renderSlot(section, {}, {
rpc: {
stewardsOverview: () => {
calls += 1;
return { paused: false, projects: [], lastSweepAt: 0 };
},
},
} as never);
await waitFor(() => expect(calls).toBe(1));
await slot.emitRealtime(PROJECT_CHANGED, { projectId: "p1" });
await waitFor(() => expect(calls).toBe(2));
slot.lifecycle.unmount();
});
});

// BB-78: the Stewards page adopts the sidebar's two-line row, glyph and phrase,
// with the Blocked / In flight / Monitors / Recently ended sections.
const boardSteward = (over: Record<string, unknown> = {}) => ({
Expand Down
18 changes: 12 additions & 6 deletions plugins/stewards/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
type PluginThreadListProps,
} from "@get-bb/plugin-sdk/app";
import type { rpcContract } from "./server";
import { NEEDS_YOU_CHANGED } from "./channels.js";
import { PROJECT_CHANGED } from "./channels.js";
import { buildSidebar, compareBlocked, compareEfforts, compareInflight, deploymentPhrase, effortPhrase, effortSection, effortState, effortTitle, isBusy, type EffortStatus, type SidebarChild, type SidebarChildRole, type SidebarEffort, type SidebarEffortState, type SidebarSection, type SidebarTab } from "./sidebar.js";
import { AUTOMATIC_THREAD_LIST_PROVIDER, BUILTIN_THREAD_LIST_ID, THREAD_LIST_PROVIDER_KEY, stewardsThreadListId } from "./sidebar-provider.js";

Expand Down Expand Up @@ -104,7 +104,13 @@ function usePolled<T>(load: () => Promise<T>): { data: T | null; error: string |

function useOverview(): { overview: Overview | null; error: string | null } {
const rpc = useRpc<typeof rpcContract>();
const { data, error } = usePolled(useCallback(() => rpc.call("stewardsOverview", null), [rpc]));
const { data, error, refetch } = usePolled(useCallback(() => rpc.call("stewardsOverview", null), [rpc]));
// The overview covers every project, so any project's visible change
// refetches it; the half-minute poll stays the fallback.
useRealtime(
PROJECT_CHANGED,
useCallback(() => refetch(), [refetch]),
);
return { overview: data, error };
}

Expand Down Expand Up @@ -457,9 +463,9 @@ function BoardPage({ subPath }: { subPath: string }) {
useEffect(() => {
if (wanted === null && boardProjectId !== null) navigate.toPluginPanel(BOARD_PATH, { subPath: boardSubPath(boardProjectId), replace: true });
}, [wanted, boardProjectId, navigate]);
// A steward's question or item shows up here without the half-minute poll.
// A steward's visible change shows up here without the half-minute poll.
useRealtime(
NEEDS_YOU_CHANGED,
PROJECT_CHANGED,
useCallback(
(payload) => {
const projectId = payload === null || typeof payload !== "object" ? null : (payload as { projectId?: unknown }).projectId;
Expand Down Expand Up @@ -1656,11 +1662,11 @@ function StewardsSidebarCore({
const needsByProject = useMemo(() => new Map((data?.projects ?? []).map((p) => [p.projectId, p.needs] as const)), [data]);
// The picker counts the yellow rows, so the pill and the sections cannot disagree (BB-76).
const counts = useMemo(() => new Map(tabs.map((t) => [t.projectId, t.blocked.filter((e) => e.state === "you").length] as const)), [tabs]);
// A steward's question or item updates the badge and block within seconds;
// A steward's visible change updates the badge and block within seconds;
// the half-minute poll stays the fallback. Only a project this sidebar shows
// triggers a refetch.
useRealtime(
NEEDS_YOU_CHANGED,
PROJECT_CHANGED,
useCallback(
(payload) => {
const projectId = payload === null || typeof payload !== "object" ? null : (payload as { projectId?: unknown }).projectId;
Expand Down
17 changes: 12 additions & 5 deletions plugins/stewards/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@
// imports only the strings and never server code (BB-46).

/**
* Published as `{ projectId }` whenever a project's "needs you" set changes:
* a question opened or answered, an escalation forwarded to the user or
* resolved, or a needs item filed, updated or closed. Every sidebar and the
* board page refetch on it; the half-minute poll is the fallback.
* Published as `{ projectId }` whenever a project's sidebar- or
* board-visible steward state changes: the "needs you" set (a question
* opened or answered, an escalation forwarded to the user or resolved, or a
* needs item filed, updated or closed) and the rest of the row (a stage,
* phase, hold, status report, requeue time, deployment state, title, link or
* ending, or a worker starting or finishing). Every sidebar, board page and
* the overview refetch on it; the half-minute poll is the fallback.
*
* The channel string stays `needs-you-changed` (BB-90): it was published
* under that name before the signal widened, and a server and client built
* from different images must keep talking across a restart.
*/
export const NEEDS_YOU_CHANGED = "needs-you-changed";
export const PROJECT_CHANGED = "needs-you-changed";
Loading
Loading