Files
fusion/packages/dashboard/app/utils/appLifecycle.ts
gsxdsm 109204c590 fix: the query class — three sweeps that never ran on a renamed board (#2818)
Three sweeps that **never ran at all** on a renamed board, plus the
shared answer the rest of the class needs. Consolidated from three
handoff branches so the helper appears once. #2811 merged, so this is my
only open PR.

`#2800` measured this class and shipped evidence deliberately without
conversions: `listTasks({ column: "<literal>" })` filters in the store,
so on a renamed board the read returns an **empty array** and the sweep
it feeds does nothing. The census scores the comparison *inside* the
loop, never the query above it.

## What was broken

| file | census count | what actually happened on a renamed board |
|---|---|---|
| `backlog-pressure-reporter.ts` | **0** | both reads empty, ratio
computed as 0/0 — **the alert never fired**, on a board that may be
under exactly the pressure it reports |
| `stale-task-reporter.ts` | **0** | both reads empty — **no stale-task
signal ever raised**, where work is most likely sitting unnoticed |
| `restart-recovery-coordinator.ts` | flagged | sweep never ran — **an
engine restart left interrupted tasks stuck with no requeue** |

Two of the three have a census count of **zero**. They contain no
lifecycle comparison at all, so they have never appeared in the backlog,
in a per-file list, or in any "N → 0" claim — and were completely inert.
**A file at zero is not evidence of anything.**

## The shared answer, and what it is not

Every existing resolver answers a **per-task** question. A query has no
task in hand, so it needs the project-level one: every column any
workflow declares for a role, unioned with the legacy ids so a board
mid-rename still finds rows under the old ones. The set is never empty,
so a caller cannot accidentally query nothing.

The header states what it is **not**: answering a per-card question from
the union would mark a card as review because some *other* workflow
calls its column review — the flat-set mistake this program has made
four times.

## The finding that generalises: the query is rarely the whole defect

`stale-task-reporter` **still reported zero after the query was fixed**
— `getTaskAgeStalenessSignal` defaults to the legacy pair, so a card the
query now returned was refused inside the signal. Converting only the
query would have looked like a fix and changed nothing.

That is a caveat on #2800's approach, offered as refinement rather than
correction: **asserting the query ARGUMENT is right when pinning a known
defect** (the outcome is 0 either way) **and insufficient when proving a
fix**, because the outcome is the only thing that distinguishes a real
conversion from a deeper one. All three conversions here assert
outcomes.

`restart-recovery` had three layers — query, a redundant re-assertion
(deleted; a test pins the `paused` guard it did contribute), and a move
destination that was **already** resolved but whose warning comment was
stale. A stale warning is its own hazard: it told the next reader a
defect existed where none did.

## Verification

- helper **8 passed** · three reporter/coordinator suites **29 passed**
- `pnpm test:gate` **161 / 13 / 487 / 71** · lint clean · `--strict`
exits 0 · four `tsc` targets clean
- each conversion revert-proven independently; the failing case is named
in each test header

## Two mistakes worth recording

**The helper's own test caught a bug in it.** My first draft wrapped the
definition loop in one `try`, and `parseWorkflowIr` **validates** rather
than parses — one malformed row would have returned legacy-only lanes
for *every* workflow, indistinguishable from the bug it exists to fix.
Now isolated per definition.

**I clobbered the core barrel** by taking `index.ts` wholesale from a
handoff branch, dropping two exports `main` had added since; three
packages stopped compiling. Taking a file from another branch takes its
whole contents, including what is now stale — for a barrel that is
nearly always wrong. Re-applied as a single edit on top of `main`.

## Not included

`self-healing.ts`'s 49 — actively owned and mid-conversion; an outside
refactor there produces conflicting halves of one sweep.
`project-engine.ts` (7) and `executor.ts` (2) need their own read of
what each sweep does with the rows, which these three are the argument
for.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:55:10 -07:00

276 lines
10 KiB
TypeScript

/*
FNXC:AppLifecycle 2026-06-24-00:00:
Module-level lifecycle helpers, storage-key constants, and banner/CLI-banner pure functions extracted out of App.tsx so the root component stays an orchestrator. Behavior is byte-identical to the former inline definitions; App.tsx re-exports the unit-tested symbols to preserve its import contract.
*/
import type { AiSessionSummary } from "../api";
import { api, relaunchCliSession } from "../api";
import type { CliActionId } from "../components/SessionNotificationBanner";
export const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed";
export const WORKING_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-working-branch-filter";
export const BASE_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-base-branch-filter";
export const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__";
export const APPROVAL_BANNER_DISMISSED_STORAGE_KEY = "fusion:approval-banner-dismissed";
export const CAPACITY_RISK_DISMISSED_KEY = "kb-capacity-risk-banner-dismissed";
export const RETRY_WARNING_RATIO = 0.8;
export interface ApprovalBannerCandidate {
dedupeKey: string;
updatedAtMs: number;
}
export function didEnterAwaitingApproval(nextStatus: string | undefined, previousStatus: string | undefined): boolean {
return nextStatus === "awaiting-approval" && previousStatus !== "awaiting-approval";
}
export function didEnterDone(nextStatus: string | undefined, previousStatus: string | undefined): boolean {
return nextStatus === "done" && previousStatus !== undefined && previousStatus !== "done";
}
export function parseDateMs(value: string | undefined): number {
if (!value) return 0;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
export function loadApprovalBannerDismissals(): Map<string, number> {
if (typeof window === "undefined") return new Map();
try {
const raw = window.localStorage.getItem(APPROVAL_BANNER_DISMISSED_STORAGE_KEY);
if (!raw) return new Map();
const parsed = JSON.parse(raw) as Record<string, number>;
const map = new Map<string, number>();
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "number" && Number.isFinite(value)) {
map.set(key, value);
}
}
return map;
} catch {
return new Map();
}
}
export function persistApprovalBannerDismissals(map: Map<string, number>): void {
if (typeof window === "undefined") return;
try {
const data: Record<string, number> = {};
for (const [key, value] of map) {
data[key] = value;
}
window.localStorage.setItem(APPROVAL_BANNER_DISMISSED_STORAGE_KEY, JSON.stringify(data));
} catch {
// no-op
}
}
export function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string {
const url = new URL(serverUrl);
if (authToken) {
url.searchParams.set("rt", authToken);
}
return url.toString();
}
export interface DesktopShellRedirectShellState {
host: "web" | "mobile-shell" | "desktop-shell";
desktopMode?: "local" | "remote";
activeProfileId: string | null;
profiles: Array<{ id: string; serverUrl: string; authToken?: string | null }>;
localRuntime?: {
state: "stopped" | "starting" | "running" | "error";
port?: number;
baseUrl?: string;
};
}
/*
* FNXC:DesktopSwitchServer 2026-07-04-13:20:
* The in-dashboard "Switch server" (Connection Manager) affordance calls `shellApi.setDesktopMode(...)` /
* `setActiveProfile(...)` directly and relies on THIS renderer-side redirect to navigate, because it does not
* route through the Electron main-process launch-mode handlers (`desktopLaunchMode:setMode` in
* packages/desktop/src/main.ts) the way the native menu does. The desktop preload only ever emits the live
* `localRuntime` field (see native-shell.d.ts / packages/desktop/src/ipc.ts) — the legacy `localServer` field is
* never populated and must not be used. This helper is the single decision point shared by both the local- and
* remote-redirect effects in App.tsx so both switch directions behave like the working native-menu path and
* so neither introduces a reload loop.
*/
export function resolveDesktopShellRedirectTarget(
shellState: DesktopShellRedirectShellState,
currentHref: string,
): string | null {
if (shellState.host !== "desktop-shell") {
return null;
}
if (shellState.desktopMode === "local") {
const runtime = shellState.localRuntime;
if (!runtime || runtime.state !== "running") {
return null;
}
const baseUrl = runtime.baseUrl || (runtime.port ? `http://localhost:${runtime.port}` : undefined);
if (!baseUrl) {
return null;
}
if (currentHref === baseUrl) {
return null;
}
try {
const current = new URL(currentHref);
const target = new URL(baseUrl);
if (current.origin === target.origin) {
return null;
}
} catch {
// fall through and navigate — currentHref/baseUrl were not parseable URLs
}
return baseUrl;
}
if (shellState.desktopMode === "remote") {
const activeProfile = shellState.profiles.find((profile) => profile.id === shellState.activeProfileId);
if (!activeProfile) {
return null;
}
const nextUrl = buildRemoteDashboardUrl(activeProfile.serverUrl, activeProfile.authToken ?? null);
if (currentHref === nextUrl) {
return null;
}
return nextUrl;
}
return null;
}
export function requiresNativeShellOnboarding(
shellState: { host: "web" | "mobile-shell" | "desktop-shell"; desktopMode?: "local" | "remote"; activeProfileId: string | null },
shellReady: boolean,
shellOnboardingComplete: boolean,
): boolean {
if (!shellReady || shellOnboardingComplete || shellState.host === "web") {
return false;
}
if (shellState.host === "mobile-shell") {
return !shellState.activeProfileId;
}
if (shellState.desktopMode === "local") {
return false;
}
return !shellState.activeProfileId;
}
export function shouldShowFirstEverBootLoader(projectsLoading: boolean, projectCount: number): boolean {
return projectsLoading && projectCount === 0;
}
export function isSessionNeedingInputForBanner(session: AiSessionSummary): boolean {
return (
session.status === "awaiting_input" ||
session.status === "error" ||
session.status === "waiting_on_input" ||
session.status === "needs_attention"
);
}
/*
FNXC:SessionBanner 2026-07-16-20:55:
FN-8229 removes the redundant footer AI pill. Planning has its own docked view
and navigation badge, so it stays out of the banner at every status, including
retained errors. The banner is the replacement progress surface for all other
in-progress and actionable sessions.
*/
export function shouldShowSessionInBanner(session: AiSessionSummary): boolean {
if (session.type === "planning") return false;
return isSessionNeedingInputForBanner(session) || session.status === "generating";
}
export function getCliActionDisabledReasonForBanner(session: AiSessionSummary, action: CliActionId): string | null {
if ((action === "advance" || action === "relaunch") && !session.cliSessionId) {
return "CLI session id is missing.";
}
return null;
}
export interface CliActionDeps {
currentProjectId?: string;
retryTask: (id: string) => Promise<unknown>;
/*
FNXC:WorkflowLifecycleColumns 2026-08-01-02:10:
`string`, not the literal type — the signature itself was pinning the destination.
Cancel moves the session's task back to the hold lane. Keyed on `"todo"`, and with the TYPE
enforcing that literal so no caller could pass anything else, the move is REJECTED on a board that
does not declare `todo` (`moves.ts` throws "Unknown column for this workflow" unless the caller sets
`recoveryRehome`, which this is not) — so **Cancel throws instead of cancelling**. A census scanning
for comparisons cannot see this: the literal lives in a call argument and a type annotation.
*/
moveTask: (id: string, column: string) => Promise<unknown>;
/*
The task's own hold lane, resolved by the caller from board-workflow metadata. Omitted → `"todo"`,
which is today's behaviour; App.tsx supplies it, so this is not an optional parameter nobody fills.
*/
resolveCancelColumn?: (taskId: string) => string | undefined;
openAuthenticationSettings: () => void;
addToast: (message: string, type: "success" | "error") => void;
apiClient?: typeof api;
relaunchCliSessionClient?: typeof relaunchCliSession;
}
export async function executeCliSessionBannerAction(
session: AiSessionSummary,
action: CliActionId,
deps: CliActionDeps,
): Promise<void> {
try {
/*
* FNXC:SessionBanner 2026-06-14-19:32:
* CLI banner verbs must either call an existing dashboard route/flow or be disabled by the banner. `advance` confirms the CLI session, `retry` and `cancel` reuse task operations keyed by the session id until summaries expose a distinct task id, and `reauthenticate` opens the existing authentication settings flow.
*
* FNXC:SessionBanner 2026-06-14-20:16:
* `relaunch` is now a supported route-backed action for resume-exhausted CLI sessions; if `cliSessionId` is absent the handler exits without firing a malformed API call, preserving the no-silent-no-op invariant through the banner disabled reason.
*/
if (action === "advance") {
if (!session.cliSessionId) {
throw new Error("CLI session id is required to advance this session.");
}
await (deps.apiClient ?? api)(`/cli-sessions/${encodeURIComponent(session.cliSessionId)}/confirm-advance`, {
method: "POST",
body: JSON.stringify({ decision: "advance", ...(deps.currentProjectId ? { projectId: deps.currentProjectId } : {}) }),
});
return;
}
if (action === "relaunch") {
if (!session.cliSessionId) return;
await (deps.relaunchCliSessionClient ?? relaunchCliSession)(session.cliSessionId, deps.currentProjectId);
deps.addToast("CLI session relaunch requested", "success");
return;
}
if (action === "retry") {
await deps.retryTask(session.id);
return;
}
if (action === "cancel") {
/* DELIBERATE-LITERAL — the unresolved-metadata default, reviewed 2026-08-01-02:10. */
await deps.moveTask(session.id, deps.resolveCancelColumn?.(session.id) ?? "todo");
return;
}
if (action === "reauthenticate") {
deps.openAuthenticationSettings();
return;
}
throw new Error("This CLI action is not supported yet.");
} catch (err) {
const message = err instanceof Error ? err.message : "CLI action failed";
deps.addToast(message, "error");
}
}