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>
278 lines
8.9 KiB
TypeScript
278 lines
8.9 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import type { AiSessionSummary } from "../../api";
|
|
import {
|
|
buildRemoteDashboardUrl,
|
|
shouldShowSessionInBanner,
|
|
isSessionNeedingInputForBanner,
|
|
resolveDesktopShellRedirectTarget,
|
|
executeCliSessionBannerAction,
|
|
} from "../appLifecycle";
|
|
|
|
function makeSession(overrides: Partial<AiSessionSummary> & Pick<AiSessionSummary, "id">): AiSessionSummary {
|
|
return {
|
|
id: overrides.id,
|
|
type: overrides.type ?? "planning",
|
|
status: overrides.status ?? "generating",
|
|
title: overrides.title ?? overrides.id,
|
|
projectId: overrides.projectId ?? null,
|
|
updatedAt: overrides.updatedAt ?? "2026-04-08T00:00:00.000Z",
|
|
};
|
|
}
|
|
|
|
/*
|
|
FNXC:SessionBanner 2026-07-16-20:55:
|
|
FN-8229 replaces the footer AI pill with the banner for non-planning progress
|
|
and actionable states. Planning remains visible only through its docked view
|
|
and navigation badge, including retained error sessions.
|
|
*/
|
|
describe("shouldShowSessionInBanner", () => {
|
|
it("includes non-planning generating, needs-input, and error sessions", () => {
|
|
for (const status of ["generating", "awaiting_input", "error"] as const) {
|
|
expect(shouldShowSessionInBanner(makeSession({ id: status, type: "subtask", status }))).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("excludes planning sessions at every status", () => {
|
|
for (const status of ["generating", "awaiting_input", "error"] as const) {
|
|
expect(shouldShowSessionInBanner(makeSession({ id: `planning-${status}`, type: "planning", status }))).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("keeps the needs-input predicate separate from generating progress", () => {
|
|
expect(isSessionNeedingInputForBanner(makeSession({ id: "generating", type: "subtask", status: "generating" }))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("resolveDesktopShellRedirectTarget", () => {
|
|
const remoteProfile = {
|
|
id: "remote-1",
|
|
serverUrl: "https://fusionstudio:4040",
|
|
authToken: "tok-123",
|
|
};
|
|
|
|
it("returns null for non-desktop-shell hosts", () => {
|
|
expect(
|
|
resolveDesktopShellRedirectTarget(
|
|
{
|
|
host: "web",
|
|
desktopMode: "local",
|
|
activeProfileId: null,
|
|
profiles: [],
|
|
localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123" },
|
|
},
|
|
"https://fusionstudio:4040/",
|
|
),
|
|
).toBeNull();
|
|
|
|
expect(
|
|
resolveDesktopShellRedirectTarget(
|
|
{
|
|
host: "mobile-shell",
|
|
desktopMode: "local",
|
|
activeProfileId: null,
|
|
profiles: [],
|
|
localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123" },
|
|
},
|
|
"https://fusionstudio:4040/",
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("returns null when desktopMode is undefined", () => {
|
|
expect(
|
|
resolveDesktopShellRedirectTarget(
|
|
{
|
|
host: "desktop-shell",
|
|
activeProfileId: null,
|
|
profiles: [],
|
|
},
|
|
"https://fusionstudio:4040/",
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("resolves the local runtime origin (baseUrl) when switching remote -> local", () => {
|
|
const target = resolveDesktopShellRedirectTarget(
|
|
{
|
|
host: "desktop-shell",
|
|
desktopMode: "local",
|
|
activeProfileId: "remote-1",
|
|
profiles: [remoteProfile],
|
|
localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123", port: 50123 },
|
|
},
|
|
"https://fusionstudio:4040/",
|
|
);
|
|
expect(target).toBe("http://127.0.0.1:50123");
|
|
});
|
|
|
|
it("falls back to localhost:<port> when localRuntime has no baseUrl", () => {
|
|
const target = resolveDesktopShellRedirectTarget(
|
|
{
|
|
host: "desktop-shell",
|
|
desktopMode: "local",
|
|
activeProfileId: null,
|
|
profiles: [],
|
|
localRuntime: { state: "running", port: 50123 },
|
|
},
|
|
"https://fusionstudio:4040/",
|
|
);
|
|
expect(target).toBe("http://localhost:50123");
|
|
});
|
|
|
|
it("returns null when the local runtime is not running", () => {
|
|
for (const state of ["stopped", "starting", "error"] as const) {
|
|
expect(
|
|
resolveDesktopShellRedirectTarget(
|
|
{
|
|
host: "desktop-shell",
|
|
desktopMode: "local",
|
|
activeProfileId: null,
|
|
profiles: [],
|
|
localRuntime: { state, baseUrl: "http://127.0.0.1:50123" },
|
|
},
|
|
"https://fusionstudio:4040/",
|
|
),
|
|
).toBeNull();
|
|
}
|
|
|
|
expect(
|
|
resolveDesktopShellRedirectTarget(
|
|
{
|
|
host: "desktop-shell",
|
|
desktopMode: "local",
|
|
activeProfileId: null,
|
|
profiles: [],
|
|
localRuntime: undefined,
|
|
},
|
|
"https://fusionstudio:4040/",
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("returns null when already on the local runtime origin", () => {
|
|
expect(
|
|
resolveDesktopShellRedirectTarget(
|
|
{
|
|
host: "desktop-shell",
|
|
desktopMode: "local",
|
|
activeProfileId: null,
|
|
profiles: [],
|
|
localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123" },
|
|
},
|
|
"http://127.0.0.1:50123/",
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("resolves buildRemoteDashboardUrl(...) when switching local -> a remote profile", () => {
|
|
const target = resolveDesktopShellRedirectTarget(
|
|
{
|
|
host: "desktop-shell",
|
|
desktopMode: "remote",
|
|
activeProfileId: "remote-1",
|
|
profiles: [remoteProfile],
|
|
localRuntime: { state: "stopped" },
|
|
},
|
|
"http://127.0.0.1:50123/",
|
|
);
|
|
expect(target).toBe(buildRemoteDashboardUrl(remoteProfile.serverUrl, remoteProfile.authToken));
|
|
});
|
|
|
|
it("returns null when already on the target remote url", () => {
|
|
const nextUrl = buildRemoteDashboardUrl(remoteProfile.serverUrl, remoteProfile.authToken);
|
|
expect(
|
|
resolveDesktopShellRedirectTarget(
|
|
{
|
|
host: "desktop-shell",
|
|
desktopMode: "remote",
|
|
activeProfileId: "remote-1",
|
|
profiles: [remoteProfile],
|
|
},
|
|
nextUrl,
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("returns null when there is no matching/active profile", () => {
|
|
expect(
|
|
resolveDesktopShellRedirectTarget(
|
|
{
|
|
host: "desktop-shell",
|
|
desktopMode: "remote",
|
|
activeProfileId: null,
|
|
profiles: [remoteProfile],
|
|
},
|
|
"http://127.0.0.1:50123/",
|
|
),
|
|
).toBeNull();
|
|
|
|
expect(
|
|
resolveDesktopShellRedirectTarget(
|
|
{
|
|
host: "desktop-shell",
|
|
desktopMode: "remote",
|
|
activeProfileId: "missing",
|
|
profiles: [remoteProfile],
|
|
},
|
|
"http://127.0.0.1:50123/",
|
|
),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-08-01-02:10:
|
|
|
|
THE INVARIANT: the CLI-session banner's Cancel returns the card to ITS OWN hold lane.
|
|
|
|
CENSUS-INVISIBLE IN TWO WAYS AT ONCE — the literal lived in a call argument AND in the dep's TYPE
|
|
(`moveTask: (id: string, column: "todo")`), so the signature itself prevented any caller from passing
|
|
anything else. No scan for comparisons could reach either.
|
|
|
|
Post-U12 the rejection in `moves.ts` is live: a move to a column the workflow does not declare throws
|
|
"Unknown column for this workflow" unless the caller sets `recoveryRehome`, which this is not. So on a
|
|
renamed board **Cancel threw instead of cancelling** — an operator-facing button that fails.
|
|
|
|
WIRED, NOT OPTIONAL. `App.tsx` supplies `resolveCancelColumn` from the board-workflow metadata it
|
|
already holds. An optional parameter no caller fills is the inert shape this program has found five
|
|
times; adding a sixth to fix a broken button would have been worse than leaving it.
|
|
|
|
REVERT PROOF, measured: restore the hardcoded `"todo"` and the renamed case moves to `todo` instead of
|
|
the board's own hold lane.
|
|
*/
|
|
describe("CLI banner cancel resolves the board's own hold lane", () => {
|
|
const baseDeps = () => ({
|
|
retryTask: vi.fn().mockResolvedValue(undefined),
|
|
moveTask: vi.fn().mockResolvedValue(undefined),
|
|
openAuthenticationSettings: vi.fn(),
|
|
addToast: vi.fn(),
|
|
});
|
|
|
|
it("moves to the RENAMED hold lane when the caller resolves one", async () => {
|
|
const deps = { ...baseDeps(), resolveCancelColumn: () => "backlog" };
|
|
|
|
await executeCliSessionBannerAction({ id: "FN-1" } as never, "cancel", deps as never);
|
|
|
|
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "backlog");
|
|
});
|
|
|
|
it("keeps the legacy destination when metadata has not resolved", async () => {
|
|
// Board-workflow metadata is absent on first paint and for remote projects; the documented
|
|
// fallback must stay exactly today's behaviour rather than refusing to cancel.
|
|
const deps = { ...baseDeps(), resolveCancelColumn: () => undefined };
|
|
|
|
await executeCliSessionBannerAction({ id: "FN-2" } as never, "cancel", deps as never);
|
|
|
|
expect(deps.moveTask).toHaveBeenCalledWith("FN-2", "todo");
|
|
});
|
|
|
|
it("keeps the legacy destination when no resolver is supplied at all", async () => {
|
|
const deps = baseDeps();
|
|
|
|
await executeCliSessionBannerAction({ id: "FN-3" } as never, "cancel", deps as never);
|
|
|
|
expect(deps.moveTask).toHaveBeenCalledWith("FN-3", "todo");
|
|
});
|
|
});
|