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:
gsxdsm
2026-07-30 15:02:11 -07:00
committed by GitHub
parent c0e647f320
commit d252c4e0cf
4 changed files with 101 additions and 3 deletions

View 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.

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { boardSummaryCard, notificationCard, taskToCard } from "../cards.js"; import { boardSummaryCard, boardToDeck, notificationCard, taskToCard } from "../cards.js";
const task = { const task = {
id: "FN-1", id: "FN-1",
@@ -31,4 +31,41 @@ describe("cards", () => {
expect(card.id).toBe("notif:FN-1:entered-column"); expect(card.id).toBe("notif:FN-1:entered-column");
expect(card.title.startsWith("In review")).toBe(true); 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"]);
});
}); });

View File

@@ -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( export function boardToDeck(
tasks: Task[], tasks: Task[],
opts: { maxCharsPerLine?: number; maxLines?: number; maxCards?: number; now?: string } = {}, opts: { maxCharsPerLine?: number; maxLines?: number; maxCards?: number; now?: string; terminalColumns?: ReadonlySet<string> } = {},
): CardDeck { ): CardDeck {
const maxCharsPerLine = opts.maxCharsPerLine ?? DEFAULT_MAX_CHARS_PER_LINE; const maxCharsPerLine = opts.maxCharsPerLine ?? DEFAULT_MAX_CHARS_PER_LINE;
const maxLines = opts.maxLines ?? DEFAULT_MAX_LINES_PER_CARD; const maxLines = opts.maxLines ?? DEFAULT_MAX_LINES_PER_CARD;
const maxCards = opts.maxCards ?? DEFAULT_MAX_CARDS_PER_DECK; const maxCards = opts.maxCards ?? DEFAULT_MAX_CARDS_PER_DECK;
const now = opts.now ?? new Date().toISOString(); const now = opts.now ?? new Date().toISOString();
const summary = boardSummary(tasks); 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 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))) .sort((a, b) => (b.updatedAt === a.updatedAt ? b.id.localeCompare(a.id) : b.updatedAt.localeCompare(a.updatedAt)))
.slice(0, Math.max(0, maxCards - 1)); .slice(0, Math.max(0, maxCards - 1));

View File

@@ -1,3 +1,4 @@
import { resolveProjectColumnsForRoles, TERMINAL_ROLES } from "@fusion/core";
import type { Task } from "@fusion/core"; import type { Task } from "@fusion/core";
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk"; import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk";
import { import {
@@ -39,6 +40,34 @@ function requestData(req: unknown): {
return { headers: candidate.headers ?? {}, query: candidate.query ?? {}, params: candidate.params ?? {} }; 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> { async function getBoardCards(req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> {
const request = requestData(req); const request = requestData(req);
const auth = requireApiKey(ctx, { headers: request.headers }); 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, maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE,
maxLines: DEFAULT_MAX_LINES_PER_CARD, maxLines: DEFAULT_MAX_LINES_PER_CARD,
maxCards, maxCards,
terminalColumns: await resolveTerminalLanes(ctx),
}); });
return { status: 200, body: { deck, generatedAt: new Date().toISOString() } }; 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, maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE,
maxLines: DEFAULT_MAX_LINES_PER_CARD, maxLines: DEFAULT_MAX_LINES_PER_CARD,
maxCards: 1, maxCards: 1,
terminalColumns: await resolveTerminalLanes(ctx),
}); });
return { status: 200, body: { summary: deck.summary, updatedAt: deck.summary.updatedAt } }; return { status: 200, body: { summary: deck.summary, updatedAt: deck.summary.updatedAt } };