fix(glasses): finished cards were crowding live work off the deck on a renamed board (#2854)
Not a labelling bug. `boardToDeck` filters finished cards out of a deck that is **capped at `maxCards`**, so on a board whose complete lane is named anything but `done`/`archived`, every finished card **consumes a slot and displaces live work**. The wearer sees fewer live tasks the more the team finishes — which reads as "nothing is happening", not as a defect. ```ts const active = tasks.filter((task) => task.column !== "archived" && task.column !== "done") .sort(...) .slice(0, Math.max(0, maxCards - 1)); // ← the cap is what makes this bite ``` ## Correcting an earlier DELIBERATE-LITERAL call This site was marked **DELIBERATE-LITERAL — no resolution source in this package**. That reasoning was inherited from the **deprecated** `fusion-plugin-even-cards`, which depends on `@fusion/plugin-sdk` alone. **This** package lists `@fusion/core` as a runtime dependency and already calls `resolveWorkflowIrById` / `resolveLifecycleColumns` in `quick-capture.ts` and `agent-actions.ts`. The half that *was* right: `cards.ts` genuinely cannot resolve anything — it takes plain `Task` rows. So the lane answer becomes a parameter and the **route** supplies it: one `listWorkflowDefinitions()` read per request regardless of board size, matching how `?columns=` already treats the board as a single pool. Best-effort, so a failed resolve leaves the deck on its documented default rather than failing the request — a slightly-wrong deck beats no deck on a pair of glasses. `terminalColumns` is in the `unwired-lane-parameter` vocabulary, so an unwired version of this parameter fails the build instead of sitting here looking converted. (That guard only learned to see this class in #2852.) ## Revert proof (measured) ``` FAIL > does not let a card in a RENAMED complete lane displace live work expected [ 'summary', 'FN-SHIPPED' ] to deeply equal [ 'summary', 'FN-LIVE' ] ``` `maxCards: 2` in the fixture is load-bearing — one summary card plus exactly one task slot, so an unfiltered finished card **displaces** the live one rather than merely joining it. A larger cap would let both through and the case would pass either way. The second case pins the degraded default (legacy `done`/`archived` still filtered when the caller resolved nothing), since most boards never rename anything. ## Not changed `cards.ts:187` — `task.column === "in-review" ? "In review" : "Moved in"` in the notification title. Genuinely cosmetic: a review card on a renamed board reads "Moved in" instead of "In review". No slot is lost and no decision is made from it, and `notificationCard` has no options object to thread a lane answer through, so converting it means widening a signature for a label. Left with the finding stated rather than silently swept in. ## Verification - `pnpm test:gate` — 161 / 487 / 13 / 71 passed - `pnpm lint` — clean - `tsc --noEmit` (`@fusion-plugin-examples/even-realities-glasses`) — clean - full plugin suite — 188 passed across 19 files - unwired-lane guard — 6/6, no new entries - census `--strict` — exit 0 Touches `board-routes.ts`, which #2849 also edits (different hunk — `parseColumns` vs the handlers), so the two merge cleanly in either order. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/glasses-terminal-lanes.md
Normal file
7
.changeset/glasses-terminal-lanes.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Finished cards no longer crowd live work off the glasses board on a renamed board.
|
||||
category: fix
|
||||
dev: `boardToDeck` filtered active cards on the literals `archived`/`done`. Because the deck is capped at `maxCards`, a renamed complete lane meant every finished card consumed a slot and displaced live work. The route now resolves the project's terminal lanes once per request via `resolveProjectColumnsForRoles` and passes `terminalColumns` down.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { boardSummaryCard, notificationCard, taskToCard } from "../cards.js";
|
||||
import { boardSummaryCard, boardToDeck, notificationCard, taskToCard } from "../cards.js";
|
||||
|
||||
const task = {
|
||||
id: "FN-1",
|
||||
@@ -31,4 +31,41 @@ describe("cards", () => {
|
||||
expect(card.id).toBe("notif:FN-1:entered-column");
|
||||
expect(card.title.startsWith("In review")).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-31-02:40:
|
||||
THE INVARIANT: a finished card never occupies a deck slot, whatever its lane is called.
|
||||
|
||||
`boardToDeck` filtered on the literals `archived`/`done`, and the consequence on a renamed board is
|
||||
not a wrong label — the deck is CAPPED at `maxCards`, so every completed card counted as active and
|
||||
pushed real work off the glasses display. The wearer sees fewer live tasks the more the team
|
||||
finishes, which reads as "nothing is happening" rather than as a bug.
|
||||
|
||||
`maxCards: 2` is the whole point of the fixture: one summary card plus exactly one task slot, so a
|
||||
finished card that is not filtered out DISPLACES the live one instead of merely joining it. A
|
||||
larger cap would let both through and the case would pass either way.
|
||||
|
||||
REVERT PROOF, measured: restore the two literals and this fails with
|
||||
`expected 'FN-SHIPPED' to be 'FN-LIVE'` — the finished card takes the only slot.
|
||||
*/
|
||||
it("does not let a card in a RENAMED complete lane displace live work", () => {
|
||||
const row = (id: string, column: string, updatedAt: string) =>
|
||||
({ ...task, id, column, updatedAt }) as never;
|
||||
|
||||
const deck = boardToDeck(
|
||||
[row("FN-SHIPPED", "shipped", "2026-01-02T00:00:00.000Z"), row("FN-LIVE", "building", "2026-01-01T00:00:00.000Z")],
|
||||
{ maxCards: 2, terminalColumns: new Set(["shipped", "vault"]) },
|
||||
);
|
||||
|
||||
expect(deck.cards.map((c) => c.id)).toEqual(["summary", "FN-LIVE"]);
|
||||
});
|
||||
|
||||
it("keeps the legacy done/archived lanes when the caller resolved nothing", () => {
|
||||
// The degraded default must still hold — most boards never rename anything.
|
||||
const row = (id: string, column: string) => ({ ...task, id, column }) as never;
|
||||
|
||||
const deck = boardToDeck([row("FN-DONE", "done"), row("FN-ARCH", "archived"), row("FN-LIVE", "todo")], { maxCards: 5 });
|
||||
|
||||
expect(deck.cards.map((c) => c.id)).toEqual(["summary", "FN-LIVE"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,17 +142,40 @@ export function taskToCard(
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-31-02:40:
|
||||
`terminalColumns` — which lanes mean "finished" — supplied by the route, not guessed here.
|
||||
|
||||
The filter below named `archived` and `done`, and on a board that calls its finished lane anything
|
||||
else the consequence is not cosmetic: the deck is capped at `maxCards`, so every completed card
|
||||
counts as active and PUSHES REAL WORK OFF the glasses display. The wearer sees a shorter list of
|
||||
live tasks the more work the team finishes.
|
||||
|
||||
Optional with a documented default so the existing `boardToDeck([task], { maxCards: 1 })` summary
|
||||
call is unchanged, and because the earlier audit of this deck was right that `cards.ts` itself has no
|
||||
resolution source — it takes plain `Task` rows. What that audit got wrong is the conclusion: the note
|
||||
it inherited was written for the DEPRECATED `fusion-plugin-even-cards`, which depends on
|
||||
`@fusion/plugin-sdk` alone. This package lists `@fusion/core` as a runtime dependency and already
|
||||
calls `resolveWorkflowIrById`/`resolveLifecycleColumns` in `quick-capture.ts` and `agent-actions.ts`,
|
||||
so the caller can resolve; only this function cannot.
|
||||
|
||||
The route resolves it once per request. `scripts/lib/unwired-lane-parameter.mjs` watches the name, so
|
||||
an unwired version of this parameter fails the build rather than sitting here looking converted.
|
||||
*/
|
||||
export function boardToDeck(
|
||||
tasks: Task[],
|
||||
opts: { maxCharsPerLine?: number; maxLines?: number; maxCards?: number; now?: string } = {},
|
||||
opts: { maxCharsPerLine?: number; maxLines?: number; maxCards?: number; now?: string; terminalColumns?: ReadonlySet<string> } = {},
|
||||
): CardDeck {
|
||||
const maxCharsPerLine = opts.maxCharsPerLine ?? DEFAULT_MAX_CHARS_PER_LINE;
|
||||
const maxLines = opts.maxLines ?? DEFAULT_MAX_LINES_PER_CARD;
|
||||
const maxCards = opts.maxCards ?? DEFAULT_MAX_CARDS_PER_DECK;
|
||||
const now = opts.now ?? new Date().toISOString();
|
||||
const summary = boardSummary(tasks);
|
||||
/* DELIBERATE-LITERAL — the degraded default when the caller resolved no lanes; see above. */
|
||||
const isTerminal = (column: string) =>
|
||||
opts.terminalColumns ? opts.terminalColumns.has(column) : column === "archived" || column === "done";
|
||||
const active = tasks
|
||||
.filter((task) => task.column !== "archived" && task.column !== "done")
|
||||
.filter((task) => !isTerminal(task.column))
|
||||
.sort((a, b) => (b.updatedAt === a.updatedAt ? b.id.localeCompare(a.id) : b.updatedAt.localeCompare(a.updatedAt)))
|
||||
.slice(0, Math.max(0, maxCards - 1));
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveProjectColumnsForRoles, TERMINAL_ROLES } from "@fusion/core";
|
||||
import type { Task } from "@fusion/core";
|
||||
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk";
|
||||
import {
|
||||
@@ -39,6 +40,34 @@ function requestData(req: unknown): {
|
||||
return { headers: candidate.headers ?? {}, query: candidate.query ?? {}, params: candidate.params ?? {} };
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowLifecycleColumns 2026-07-31-02:40:
|
||||
The project's terminal lanes, resolved once per request and handed to the deck builder.
|
||||
|
||||
`boardToDeck` filters out finished cards, and it named `archived`/`done`. On a board that calls its
|
||||
finished lane anything else the deck is not merely mislabelled: it is CAPPED at `maxCards`, so every
|
||||
completed card counts as active and pushes real work off the glasses display — the wearer sees fewer
|
||||
live tasks the more the team finishes.
|
||||
|
||||
`boardToDeck` takes plain `Task` rows and cannot resolve anything itself, but this package lists
|
||||
`@fusion/core` as a runtime dependency (`quick-capture.ts` and `agent-actions.ts` already resolve
|
||||
workflow IRs), so the route can. Project-scoped rather than per card: one `listWorkflowDefinitions()`
|
||||
read regardless of board size, matching how `?columns=` already treats the board as one pool.
|
||||
|
||||
Best-effort — a failed resolve leaves the deck on its documented legacy default rather than failing
|
||||
the request, because a slightly-wrong deck beats no deck on a pair of glasses.
|
||||
*/
|
||||
async function resolveTerminalLanes(ctx: PluginContext): Promise<ReadonlySet<string> | undefined> {
|
||||
try {
|
||||
return await resolveProjectColumnsForRoles(
|
||||
ctx.taskStore as Parameters<typeof resolveProjectColumnsForRoles>[0],
|
||||
TERMINAL_ROLES,
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function getBoardCards(req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> {
|
||||
const request = requestData(req);
|
||||
const auth = requireApiKey(ctx, { headers: request.headers });
|
||||
@@ -52,6 +81,7 @@ async function getBoardCards(req: unknown, ctx: PluginContext): Promise<PluginRo
|
||||
maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE,
|
||||
maxLines: DEFAULT_MAX_LINES_PER_CARD,
|
||||
maxCards,
|
||||
terminalColumns: await resolveTerminalLanes(ctx),
|
||||
});
|
||||
return { status: 200, body: { deck, generatedAt: new Date().toISOString() } };
|
||||
}
|
||||
@@ -68,6 +98,7 @@ async function getBoardSummary(req: unknown, ctx: PluginContext): Promise<Plugin
|
||||
maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE,
|
||||
maxLines: DEFAULT_MAX_LINES_PER_CARD,
|
||||
maxCards: 1,
|
||||
terminalColumns: await resolveTerminalLanes(ctx),
|
||||
});
|
||||
|
||||
return { status: 200, body: { summary: deck.summary, updatedAt: deck.summary.updatedAt } };
|
||||
|
||||
Reference in New Issue
Block a user