fix: hide stale task-card oversight icons (#2318)
## Summary Task cards now remove the overseer eye as soon as their workflow's effective oversight becomes `off`, instead of reusing an active value cached before the setting changed. The invalidation covers card remounts as well as mounted cards, and authoritative writes from the dashboard, agents, and configuration rollback reach the board through the existing project-scoped SSE stream. Older in-flight responses cannot restore the eye after a newer `off` value wins, while unrelated workflow-setting saves leave active indicators undisturbed. ## Validation - 99 focused dashboard regression tests passed across selected and aggregate cards, desktop and mobile, SSE delivery, and out-of-order responses - Core and dashboard typechecks passed - `pnpm lint` and `pnpm check:changesets` passed - `pnpm verify:fast` passed production builds and the CLI/server boot smoke <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Planner-overseer eye badges now disappear immediately when workflow oversight is turned off. - Prevented stale or out-of-order updates from displaying incorrect oversight status. - Oversight indicators now remain hidden when the effective setting cannot be confirmed. - Live workflow setting changes now update task cards without requiring a page refresh. - **Documentation** - Clarified eye badge visibility rules, tooltip meaning, and active oversight states in the dashboard guide. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
7
.changeset/fix-task-card-overseer-cache.md
Normal file
7
.changeset/fix-task-card-overseer-cache.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Hide task-card overseer eyes immediately after workflow oversight is turned off.
|
||||
category: fix
|
||||
dev: Invalidates TaskCard resolution across remounts and authoritative workflow-setting SSE mutations.
|
||||
@@ -242,7 +242,8 @@ Features:
|
||||
<!-- FNXC:PlannerOversight 2026-07-17-15:50: FN-8251 requires selected-workflow cards to resolve inherited oversight from their trusted board workflow ID when aggregate workflowBadge metadata is absent. Identity-less, pending, failed, and malformed inherited resolution fails closed: the Eye and otherwise-empty card-header-badges wrapper appear only after active effective oversight is positively resolved. -->
|
||||
<!-- FNXC:TaskRevert 2026-07-16-00:00: FN-8066 adds durable source-task revert provenance to the shared board/List TaskCard footer. -->
|
||||
- Completed and archived task cards show a compact **Reverted** footer chip after a clean or already-reverted git outcome has persisted the source task's revert marker; conflicts, AI undo tasks, and revert PRs awaiting merge do not show it.
|
||||
- Task cards show a compact **planner-overseer eye badge** (`data-testid="planner-overseer-state-badge"`) only when the engine reports a non-idle, non-off transient `plannerOverseerState` **and** the task's effective oversight is positively resolved as active and meaningfully configured. The eye uses the same inherited-default suppression as the oversight-level badge: a workflow declaration-default `autonomous` tier with no explicit per-task override shows neither eye nor an otherwise-empty `.card-header-badges` wrapper, while an explicit per-task `autonomous` override or resolved `observe`/`steer` tier can show it. Aggregate cards resolve inherited oversight through their task workflow badge; selected-workflow board cards resolve it through their trusted board workflow ID. Identity-less cards and pending, failed, or malformed inherited workflow-setting loads fail closed. When effective oversight is off or cannot be positively resolved, the eye badge and otherwise-empty `.card-header-badges` wrapper are both absent. This matches—but does not alter—the separate Task Detail `EyeOff` menu trigger. The eye badge is an active-overseer state marker, not a human-read/view indicator: `watching` means passive monitoring, `steering`/`recovering` mean active guidance or recovery is underway, and `awaiting-confirmation` means a human decision is required before the overseer can continue. Hover exposes the composed tooltip with the overseer's reason, watched stage/signal, and pending-confirmation note when present.
|
||||
<!-- FNXC:PlannerOversight 2026-07-18-13:35: Successful workflow-setting writes immediately invalidate task-card oversight resolution, and completed effective values are never reused across card remounts, so an earlier active value cannot authorize the Eye after oversight turns off. -->
|
||||
- Task cards show a compact **planner-overseer eye badge** (`data-testid="planner-overseer-state-badge"`) only when the engine reports a non-idle, non-off transient `plannerOverseerState` **and** the task's effective oversight is positively resolved as active and meaningfully configured. The eye uses the same inherited-default suppression as the oversight-level badge: a workflow declaration-default `autonomous` tier with no explicit per-task override shows neither eye nor an otherwise-empty `.card-header-badges` wrapper, while an explicit per-task `autonomous` override or resolved `observe`/`steer` tier can show it. Aggregate cards resolve inherited oversight through their task workflow badge; selected-workflow board cards resolve it through their trusted board workflow ID. Identity-less cards and pending, failed, or malformed inherited workflow-setting loads fail closed. Successful workflow-setting writes invalidate that resolution immediately, and completed values are not retained across card remounts, so changing a workflow to `off` cannot leave an eye authorized by an earlier active value. When effective oversight is off or cannot be positively resolved, the eye badge and otherwise-empty `.card-header-badges` wrapper are both absent. This matches—but does not alter—the separate Task Detail `EyeOff` menu trigger. The eye badge is an active-overseer state marker, not a human-read/view indicator: `watching` means passive monitoring, `steering`/`recovering` mean active guidance or recovery is underway, and `awaiting-confirmation` means a human decision is required before the overseer can continue. Hover exposes the composed tooltip with the overseer's reason, watched stage/signal, and pending-confirmation note when present.
|
||||
<!-- FNXC:PlannerOversight 2026-07-04-17:00: FN-7517 adds interactive task-detail planner-overseer controls (quick level change, manual nudge, stop oversight, explain current action) alongside the FN-7516 read-only card badges above. These controls live ONLY in TaskDetailModal, not TaskCard.
|
||||
|
||||
FNXC:PlannerOversight 2026-07-05-00:00: FN-7604 collapses the desktop inline cluster (documented below through FN-7545/FN-7546) into the single universal overflow-menu dropdown that FN-7545 originally built for mobile only — the dropdown is now the ONE canonical surface on every viewport, desktop included.
|
||||
|
||||
@@ -70,10 +70,23 @@ pgDescribe("PostgreSQL workflow authoritative reads", () => {
|
||||
it("lists and exports project-scoped PostgreSQL workflow setting values", async () => {
|
||||
const store = h.store();
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
const settingUpdate = new Promise<{
|
||||
workflowId: string;
|
||||
projectId: string;
|
||||
settingIds: string[];
|
||||
mutationId: string;
|
||||
}>((resolve) => store.once("workflow:setting-values-updated", resolve));
|
||||
await store.updateWorkflowSettingValues("builtin:coding", projectId, {
|
||||
workflowStepTimeoutMs: 420_000,
|
||||
});
|
||||
|
||||
await expect(settingUpdate).resolves.toMatchObject({
|
||||
workflowId: "builtin:coding",
|
||||
projectId,
|
||||
settingIds: ["workflowStepTimeoutMs"],
|
||||
mutationId: expect.any(String),
|
||||
});
|
||||
|
||||
const expected = {
|
||||
"builtin:coding": { workflowStepTimeoutMs: 420_000 },
|
||||
};
|
||||
|
||||
@@ -148,6 +148,12 @@ export interface TaskStoreEvents {
|
||||
"task:deleted": [task: Task, meta?: { githubIssueAction?: GithubIssueAction }];
|
||||
"task:merged": [result: MergeResult];
|
||||
"settings:updated": [data: { settings: Settings; previous: Settings }];
|
||||
"workflow:setting-values-updated": [data: {
|
||||
workflowId: string;
|
||||
projectId: string;
|
||||
settingIds: string[];
|
||||
mutationId: string;
|
||||
}];
|
||||
"artifact:registered": [artifact: Artifact];
|
||||
"artifact:updated": [artifact: Artifact];
|
||||
"agent:log": [entry: AgentLogEntry];
|
||||
|
||||
@@ -641,7 +641,7 @@ export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflow
|
||||
*/
|
||||
if (store.backendMode) {
|
||||
const layer = store.asyncLayer!;
|
||||
return layer.transactionImmediate(async (tx) => {
|
||||
const committed = await layer.transactionImmediate(async (tx) => {
|
||||
const rows = await tx
|
||||
.select({ values: schema.project.workflowSettings.values })
|
||||
.from(schema.project.workflowSettings)
|
||||
@@ -690,8 +690,17 @@ export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflow
|
||||
changedBy,
|
||||
});
|
||||
if (revision) await appendConfigurationRevision(tx, revision);
|
||||
return next;
|
||||
return { next, revision };
|
||||
});
|
||||
if (committed.revision) {
|
||||
store.emit("workflow:setting-values-updated", {
|
||||
workflowId,
|
||||
projectId,
|
||||
settingIds: committed.revision.diffs.map((diff) => diff.field),
|
||||
mutationId: committed.revision.id,
|
||||
});
|
||||
}
|
||||
return committed.next;
|
||||
}
|
||||
return store.db.transactionImmediate(() => {
|
||||
const current = store.getWorkflowSettingValues(workflowId, projectId);
|
||||
@@ -770,6 +779,12 @@ export async function rollbackConfigurationImpl(store: TaskStore, revisionId: st
|
||||
// Workflow VALUE changes do not alter the merged project settings object,
|
||||
// but settings consumers still need the standard invalidation signal.
|
||||
store.emit("settings:updated", { settings: await store.getSettings(), previous });
|
||||
store.emit("workflow:setting-values-updated", {
|
||||
workflowId: String(projectRevision.configTarget.workflowId),
|
||||
projectId: String(projectRevision.configTarget.projectId),
|
||||
settingIds: rollback.diffs.map((diff) => diff.field),
|
||||
mutationId: rollback.id,
|
||||
});
|
||||
}
|
||||
return rollback;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { updateWorkflowSettingValues } from "../legacy";
|
||||
import {
|
||||
__test_clearWorkflowSettingValuesRevisions,
|
||||
getWorkflowSettingValuesRevision,
|
||||
} from "../../utils/workflowSettingValuesEvents";
|
||||
|
||||
function response(ok: boolean, body: unknown, status = ok ? 200 : 500): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? "OK" : "Error",
|
||||
headers: { get: () => "application/json" },
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
__test_clearWorkflowSettingValuesRevisions();
|
||||
});
|
||||
|
||||
describe("workflow setting value update invalidation", () => {
|
||||
it("advances the matching workflow revision after a successful write", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response(true, {
|
||||
stored: { plannerOversightLevel: "off" },
|
||||
effective: { plannerOversightLevel: "off" },
|
||||
orphaned: [],
|
||||
})));
|
||||
|
||||
await updateWorkflowSettingValues("builtin:coding", { plannerOversightLevel: "off" }, "project-1");
|
||||
|
||||
expect(getWorkflowSettingValuesRevision("builtin:coding", "project-1")).toBe(1);
|
||||
expect(getWorkflowSettingValuesRevision("builtin:coding", "project-2")).toBe(0);
|
||||
});
|
||||
|
||||
it("does not invalidate cards when the workflow write fails", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response(false, { error: "failed" })));
|
||||
|
||||
await expect(updateWorkflowSettingValues("builtin:coding", { plannerOversightLevel: "off" }, "project-1")).rejects.toThrow();
|
||||
|
||||
expect(getWorkflowSettingValuesRevision("builtin:coding", "project-1")).toBe(0);
|
||||
});
|
||||
|
||||
it("does not blink oversight UI for a successful unrelated setting write", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response(true, {
|
||||
stored: { planningFallbackThinkingLevel: "high" },
|
||||
effective: { planningFallbackThinkingLevel: "high" },
|
||||
orphaned: [],
|
||||
})));
|
||||
|
||||
await updateWorkflowSettingValues("builtin:coding", { planningFallbackThinkingLevel: "high" }, "project-1");
|
||||
|
||||
expect(getWorkflowSettingValuesRevision("builtin:coding", "project-1")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -298,6 +298,7 @@ import type { MemoryFileInfo } from "./memory.js";
|
||||
import { api, ApiRequestError, buildApiUrl, looksLikeHtml, proxyApi } from "./client.js";
|
||||
import type { FetchOptions } from "./client.js";
|
||||
import { withProjectId } from "./health.js";
|
||||
import { notifyWorkflowSettingValuesUpdated } from "../utils/workflowSettingValuesEvents.js";
|
||||
|
||||
// Import + re-export skills types so legacy monofile bodies can reference them
|
||||
// while hooks/components keep stable import paths via this barrel.
|
||||
@@ -4737,18 +4738,22 @@ export function fetchWorkflowSettingValues(
|
||||
* `values` map is validated against the named workflow's declarations; a `null`
|
||||
* value deletes that key. A typed rejection surfaces as an ApiRequestError with
|
||||
* `status: 400` and `details.rejections: WorkflowSettingRejection[]`. */
|
||||
export function updateWorkflowSettingValues(
|
||||
export async function updateWorkflowSettingValues(
|
||||
id: string,
|
||||
values: Record<string, unknown>,
|
||||
projectId?: string,
|
||||
): Promise<WorkflowSettingValuesPayload> {
|
||||
return api<WorkflowSettingValuesPayload>(
|
||||
const payload = await api<WorkflowSettingValuesPayload>(
|
||||
withProjectId(`/workflows/${encodeURIComponent(id)}/setting-values`, projectId),
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ values }),
|
||||
},
|
||||
);
|
||||
if (Object.prototype.hasOwnProperty.call(values, "plannerOversightLevel")) {
|
||||
notifyWorkflowSettingValuesUpdated(id, projectId);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Read per-node prompt overrides for a workflow in the current project context. */
|
||||
|
||||
@@ -58,6 +58,12 @@ import { WorkflowIcon } from "./WorkflowIcon";
|
||||
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnFlags, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu";
|
||||
import { formatCost, hasTaskCost, taskTotalCost } from "../utils/taskTokenCost";
|
||||
import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator";
|
||||
import {
|
||||
WORKFLOW_SETTING_VALUES_UPDATED_EVENT,
|
||||
getWorkflowSettingValuesKey,
|
||||
getWorkflowSettingValuesRevision,
|
||||
type WorkflowSettingValuesUpdatedDetail,
|
||||
} from "../utils/workflowSettingValuesEvents";
|
||||
|
||||
/** Per-branch progress snapshot (U13). Surfaced as an optional additive field
|
||||
* on the task payload for the parallel-window badge (U9). */
|
||||
@@ -122,7 +128,7 @@ async function getAgentName(agentId: string, projectId?: string): Promise<string
|
||||
// ── Workflow-effective planner-oversight-level caching ─────────────────────
|
||||
|
||||
/*
|
||||
* FNXC:PlannerOversight 2026-07-04-12:30:
|
||||
* FNXC:PlannerOversight 2026-07-18-13:18:
|
||||
* Code review (FN-7516) flagged that always resolving with an `undefined`
|
||||
* workflow tier makes every task without a per-task override display
|
||||
* "Autonomous recovery", even when the task's workflow was explicitly
|
||||
@@ -130,15 +136,16 @@ async function getAgentName(agentId: string, projectId?: string): Promise<string
|
||||
* `plannerOversightLevel` setting value is NOT present on the Task payload
|
||||
* (verified: no such field exists in packages/core/src/types.ts or in any
|
||||
* task-list/detail serialization path), so the card cannot read it via
|
||||
* `task.*` alone. Rather than plumb a new prop through the five card call
|
||||
* sites (out of this task's scope; see PROMPT.md File Scope note) or thread a
|
||||
* new field through the task-store/API contract (a bigger, separate change),
|
||||
* this mirrors the established card-local caching pattern already used for
|
||||
* mission titles/agent names above: a module-level cache keyed by
|
||||
* `(projectId, workflowId)`, populated by a self-contained fetch to the
|
||||
* `task.*` alone. The card therefore resolves the authoritative value through
|
||||
* a request keyed by `(projectId, workflowId)`, populated
|
||||
* by a self-contained fetch to the
|
||||
* existing `GET /api/workflows/:id/setting-values` route (already used by the
|
||||
* workflow editor's Values tab), with in-flight de-duplication so many cards
|
||||
* sharing one workflow trigger a single network call. Round-2 code review:
|
||||
* workflow editor's Values tab), with in-flight de-duplication so cards sharing
|
||||
* one workflow trigger a single network call. Completed values are deliberately
|
||||
* not cached across mounts. Successful local writes and authoritative
|
||||
* workflow-setting SSE mutations invalidate mounted cards, so turning oversight
|
||||
* off cannot retain an old active tier across browser/server write surfaces.
|
||||
* Round-2 code review:
|
||||
* the very first render before the fetch resolves must NOT show a guessed
|
||||
* schema-default badge — see `workflowOversightResolved` near the effect
|
||||
* below, which gates both oversight badges until the workflow tier is known
|
||||
@@ -152,19 +159,18 @@ type WorkflowOversightResolution = {
|
||||
resolved: boolean;
|
||||
/** FNXC:PlannerOversight 2026-07-17-15:50: Cache identity prevents an old workflow's active tier leaking during a prop switch. */
|
||||
workflowCacheKey?: string;
|
||||
settingsRevision?: number;
|
||||
};
|
||||
|
||||
const workflowOversightEffectiveCache = new Map<string, WorkflowOversightResolution>();
|
||||
const workflowOversightInflight = new Map<string, Promise<void>>();
|
||||
const workflowOversightInflight = new Map<string, Promise<WorkflowOversightResolution>>();
|
||||
|
||||
/** @internal Test helper to reset the workflow-effective-oversight cache between tests */
|
||||
/** @internal Test helper to reset in-flight workflow oversight requests between tests */
|
||||
export function __test_clearWorkflowOversightEffectiveCache(): void {
|
||||
workflowOversightEffectiveCache.clear();
|
||||
workflowOversightInflight.clear();
|
||||
}
|
||||
|
||||
function getWorkflowOversightCacheKey(workflowId: string, projectId?: string): string {
|
||||
return `${projectId ?? "default"}::${workflowId}`;
|
||||
return getWorkflowSettingValuesKey(workflowId, projectId);
|
||||
}
|
||||
|
||||
function normalizeWorkflowId(value: string | null | undefined): string | undefined {
|
||||
@@ -176,34 +182,31 @@ function isPlannerOversightLevelValue(value: unknown): value is PlannerOversight
|
||||
return typeof value === "string" && (PLANNER_OVERSIGHT_LEVELS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Fetch (with in-flight de-dup) and cache the workflow's effective
|
||||
/** Fetch (with in-flight de-dup) the workflow's current effective
|
||||
* `plannerOversightLevel` setting value for a given `(workflowId, projectId)`.
|
||||
* Never throws; failed or malformed responses stay explicitly unresolved so
|
||||
* callers cannot mistake an unknown inherited tier for the schema default. */
|
||||
async function loadWorkflowOversightEffectiveLevel(workflowId: string, projectId: string | undefined): Promise<WorkflowOversightResolution> {
|
||||
const key = getWorkflowOversightCacheKey(workflowId, projectId);
|
||||
const cached = workflowOversightEffectiveCache.get(key);
|
||||
if (cached) return cached;
|
||||
const revision = getWorkflowSettingValuesRevision(workflowId, projectId);
|
||||
const inflightKey = `${key}::${revision}`;
|
||||
|
||||
let inflight = workflowOversightInflight.get(key);
|
||||
let inflight = workflowOversightInflight.get(inflightKey);
|
||||
if (!inflight) {
|
||||
inflight = fetchWorkflowSettingValues(workflowId, projectId)
|
||||
.then((payload) => {
|
||||
const raw = payload.effective?.plannerOversightLevel;
|
||||
workflowOversightEffectiveCache.set(key, isPlannerOversightLevelValue(raw)
|
||||
? { level: raw, resolved: true }
|
||||
: { level: undefined, resolved: false });
|
||||
})
|
||||
.catch(() => {
|
||||
workflowOversightEffectiveCache.set(key, { level: undefined, resolved: false });
|
||||
return isPlannerOversightLevelValue(raw)
|
||||
? { level: raw, resolved: true, settingsRevision: revision }
|
||||
: { level: undefined, resolved: false, settingsRevision: revision };
|
||||
})
|
||||
.catch(() => ({ level: undefined, resolved: false, settingsRevision: revision }))
|
||||
.finally(() => {
|
||||
workflowOversightInflight.delete(key);
|
||||
workflowOversightInflight.delete(inflightKey);
|
||||
});
|
||||
workflowOversightInflight.set(key, inflight);
|
||||
workflowOversightInflight.set(inflightKey, inflight);
|
||||
}
|
||||
await inflight;
|
||||
return workflowOversightEffectiveCache.get(key) ?? { level: undefined, resolved: false };
|
||||
return inflight;
|
||||
}
|
||||
|
||||
function normalizeTaskPriorityValue(priority: Task["priority"]): TaskPriority {
|
||||
@@ -1050,13 +1053,7 @@ function TaskCardComponent({
|
||||
const workflowOversightCacheKey = workflowIdForOversight
|
||||
? getWorkflowOversightCacheKey(workflowIdForOversight, projectId)
|
||||
: undefined;
|
||||
const [workflowOversightState, setWorkflowOversightState] = useState<WorkflowOversightResolution>(() => {
|
||||
if (!workflowOversightCacheKey) return { level: undefined, resolved: false };
|
||||
return {
|
||||
...(workflowOversightEffectiveCache.get(workflowOversightCacheKey) ?? { level: undefined, resolved: false }),
|
||||
workflowCacheKey: workflowOversightCacheKey,
|
||||
};
|
||||
});
|
||||
const [workflowOversightState, setWorkflowOversightState] = useState<WorkflowOversightResolution>({ level: undefined, resolved: false });
|
||||
useEffect(() => {
|
||||
if (!workflowIdForOversight || !workflowOversightCacheKey) {
|
||||
setWorkflowOversightState({ level: undefined, resolved: false });
|
||||
@@ -1065,23 +1062,39 @@ function TaskCardComponent({
|
||||
|
||||
const workflowId = workflowIdForOversight;
|
||||
const key = workflowOversightCacheKey;
|
||||
const cached = workflowOversightEffectiveCache.get(key);
|
||||
if (cached) {
|
||||
setWorkflowOversightState({ ...cached, workflowCacheKey: key });
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkflowOversightState({ level: undefined, resolved: false, workflowCacheKey: key });
|
||||
let cancelled = false;
|
||||
void loadWorkflowOversightEffectiveLevel(workflowId, projectId).then((resolution) => {
|
||||
if (!cancelled) setWorkflowOversightState({ ...resolution, workflowCacheKey: key });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
const resolveCurrentLevel = () => {
|
||||
setWorkflowOversightState({
|
||||
level: undefined,
|
||||
resolved: false,
|
||||
workflowCacheKey: key,
|
||||
settingsRevision: getWorkflowSettingValuesRevision(workflowId, projectId),
|
||||
});
|
||||
void loadWorkflowOversightEffectiveLevel(workflowId, projectId).then((resolution) => {
|
||||
if (!cancelled && resolution.settingsRevision === getWorkflowSettingValuesRevision(workflowId, projectId)) {
|
||||
setWorkflowOversightState({ ...resolution, workflowCacheKey: key });
|
||||
}
|
||||
});
|
||||
};
|
||||
const handleWorkflowSettingsUpdated = (event: Event) => {
|
||||
const detail = (event as CustomEvent<WorkflowSettingValuesUpdatedDetail>).detail;
|
||||
if (detail?.workflowId === workflowId && detail.projectId === projectId) resolveCurrentLevel();
|
||||
};
|
||||
|
||||
resolveCurrentLevel();
|
||||
window.addEventListener(WORKFLOW_SETTING_VALUES_UPDATED_EVENT, handleWorkflowSettingsUpdated);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener(WORKFLOW_SETTING_VALUES_UPDATED_EVENT, handleWorkflowSettingsUpdated);
|
||||
};
|
||||
}, [workflowIdForOversight, workflowOversightCacheKey, projectId]);
|
||||
// FNXC:PlannerOversight 2026-07-17-15:50: Switching a memoized card between
|
||||
// workflows must fail closed in the render before its effect resets state;
|
||||
// an active tier resolved for the prior workflow cannot authorize this Eye.
|
||||
const currentWorkflowOversightState = workflowOversightState.workflowCacheKey === workflowOversightCacheKey
|
||||
&& workflowOversightState.settingsRevision === (workflowIdForOversight
|
||||
? getWorkflowSettingValuesRevision(workflowIdForOversight, projectId)
|
||||
: undefined)
|
||||
? workflowOversightState
|
||||
: { level: undefined, resolved: false };
|
||||
const workflowOversightEffectiveLevel = currentWorkflowOversightState.level;
|
||||
|
||||
@@ -18,10 +18,14 @@
|
||||
* to render on.
|
||||
*/
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { TaskCard, __test_areTaskCardPropsEqual, __test_clearWorkflowOversightEffectiveCache } from "../TaskCard";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { fetchWorkflowSettingValues } from "../../api";
|
||||
import {
|
||||
__test_clearWorkflowSettingValuesRevisions,
|
||||
notifyWorkflowSettingValuesUpdated,
|
||||
} from "../../utils/workflowSettingValuesEvents";
|
||||
|
||||
|
||||
|
||||
@@ -112,6 +116,7 @@ function renderCard(
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__test_clearWorkflowOversightEffectiveCache();
|
||||
__test_clearWorkflowSettingValuesRevisions();
|
||||
});
|
||||
|
||||
describe("TaskCard effective oversight-level badge (FN-7516)", () => {
|
||||
@@ -549,6 +554,81 @@ describe("TaskCard selected-workflow oversight identity (FN-8251)", () => {
|
||||
expect(await screen.findByTestId("planner-overseer-state-badge")).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["selected desktop", 1280, { planningWorkflowId: "workflow-setting-changed-selected-desktop" }],
|
||||
["selected mobile", 375, { planningWorkflowId: "workflow-setting-changed-selected-mobile" }],
|
||||
["aggregate desktop", 1280, { workflowBadge: { workflowId: "workflow-setting-changed-aggregate-desktop", workflowName: "Aggregate" } }],
|
||||
["aggregate mobile", 375, { workflowBadge: { workflowId: "workflow-setting-changed-aggregate-mobile", workflowName: "Aggregate" } }],
|
||||
] as const)("re-resolves the same workflow after oversight changes to off on %s cards", async (_surface, width, props) => {
|
||||
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
|
||||
vi.mocked(fetchWorkflowSettingValues)
|
||||
.mockResolvedValueOnce({ stored: { plannerOversightLevel: "steer" }, effective: { plannerOversightLevel: "steer" }, orphaned: [] })
|
||||
.mockResolvedValueOnce({ stored: { plannerOversightLevel: "off" }, effective: { plannerOversightLevel: "off" }, orphaned: [] });
|
||||
const task = makeTask(staleSnapshot("in-progress"));
|
||||
const firstRender = render(<TaskCard task={task} onOpenDetail={noop} addToast={noop} {...props} />);
|
||||
|
||||
expect(await screen.findByTestId("planner-overseer-state-badge")).toBeTruthy();
|
||||
firstRender.unmount();
|
||||
|
||||
const { container } = render(<TaskCard task={task} onOpenDetail={noop} addToast={noop} {...props} />);
|
||||
|
||||
await waitFor(() => expect(fetchWorkflowSettingValues).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("planner-overseer-state-badge")).toBeNull();
|
||||
expect(screen.queryByTestId("card-header-badges")).toBeNull();
|
||||
expect(container.querySelector(".card-planner-overseer-state[title][aria-label]")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["selected desktop", 1280, "mounted-selected-desktop", { planningWorkflowId: "mounted-selected-desktop" }],
|
||||
["selected mobile", 375, "mounted-selected-mobile", { planningWorkflowId: "mounted-selected-mobile" }],
|
||||
["aggregate desktop", 1280, "mounted-aggregate-desktop", { workflowBadge: { workflowId: "mounted-aggregate-desktop", workflowName: "Aggregate" } }],
|
||||
["aggregate mobile", 375, "mounted-aggregate-mobile", { workflowBadge: { workflowId: "mounted-aggregate-mobile", workflowName: "Aggregate" } }],
|
||||
] as const)("hides a mounted %s card immediately when oversight is turned off", async (_surface, width, workflowId, props) => {
|
||||
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
|
||||
vi.mocked(fetchWorkflowSettingValues)
|
||||
.mockResolvedValueOnce({ stored: { plannerOversightLevel: "steer" }, effective: { plannerOversightLevel: "steer" }, orphaned: [] })
|
||||
.mockResolvedValueOnce({ stored: { plannerOversightLevel: "off" }, effective: { plannerOversightLevel: "off" }, orphaned: [] });
|
||||
const { container } = renderCard(staleSnapshot("in-progress"), { ...props, projectId: "project-cache-fix" });
|
||||
|
||||
expect(await screen.findByTestId("planner-overseer-state-badge")).toBeTruthy();
|
||||
act(() => notifyWorkflowSettingValuesUpdated(workflowId, "project-cache-fix"));
|
||||
|
||||
expect(screen.queryByTestId("planner-overseer-state-badge")).toBeNull();
|
||||
expect(screen.queryByTestId("card-header-badges")).toBeNull();
|
||||
expect(container.querySelector(".card-planner-overseer-state[title][aria-label]")).toBeNull();
|
||||
await waitFor(() => expect(fetchWorkflowSettingValues).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("planner-overseer-state-badge")).toBeNull();
|
||||
expect(screen.queryByTestId("card-header-badges")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores an older active fetch that resolves after the newer off revision", async () => {
|
||||
let resolveActive: ((payload: Awaited<ReturnType<typeof fetchWorkflowSettingValues>>) => void) | undefined;
|
||||
let resolveOff: ((payload: Awaited<ReturnType<typeof fetchWorkflowSettingValues>>) => void) | undefined;
|
||||
vi.mocked(fetchWorkflowSettingValues)
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveActive = resolve; }))
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveOff = resolve; }));
|
||||
renderCard(staleSnapshot("in-progress"), { planningWorkflowId: "out-of-order-workflow", projectId: "project-cache-fix" });
|
||||
|
||||
await waitFor(() => expect(fetchWorkflowSettingValues).toHaveBeenCalledTimes(1));
|
||||
act(() => notifyWorkflowSettingValuesUpdated("out-of-order-workflow", "project-cache-fix"));
|
||||
await waitFor(() => expect(fetchWorkflowSettingValues).toHaveBeenCalledTimes(2));
|
||||
|
||||
await act(async () => {
|
||||
resolveOff?.({ stored: { plannerOversightLevel: "off" }, effective: { plannerOversightLevel: "off" }, orphaned: [] });
|
||||
});
|
||||
expect(screen.queryByTestId("planner-overseer-state-badge")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveActive?.({ stored: { plannerOversightLevel: "steer" }, effective: { plannerOversightLevel: "steer" }, orphaned: [] });
|
||||
});
|
||||
expect(screen.queryByTestId("planner-overseer-state-badge")).toBeNull();
|
||||
expect(screen.queryByTestId("card-header-badges")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the eye synchronously when an active selected workflow changes to an unresolved off workflow", async () => {
|
||||
vi.mocked(fetchWorkflowSettingValues)
|
||||
.mockResolvedValueOnce({ stored: { plannerOversightLevel: "steer" }, effective: { plannerOversightLevel: "steer" }, orphaned: [] })
|
||||
|
||||
@@ -3,6 +3,10 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useBoardWorkflows } from "../useBoardWorkflows";
|
||||
import type { BoardWorkflowsPayload } from "../../api";
|
||||
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../../utils/boardWorkflowSelection";
|
||||
import {
|
||||
__test_clearWorkflowSettingValuesRevisions,
|
||||
getWorkflowSettingValuesRevision,
|
||||
} from "../../utils/workflowSettingValuesEvents";
|
||||
|
||||
function makePayload(overrides: Partial<BoardWorkflowsPayload> = {}): BoardWorkflowsPayload {
|
||||
return {
|
||||
@@ -26,6 +30,7 @@ describe("useBoardWorkflows", () => {
|
||||
unsubscribe = vi.fn();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
__test_clearWorkflowSettingValuesRevisions();
|
||||
});
|
||||
|
||||
function makeDeps(fetchImpl: () => Promise<BoardWorkflowsPayload>) {
|
||||
@@ -154,6 +159,29 @@ describe("useBoardWorkflows", () => {
|
||||
expect(deps.writeBoardWorkflowsCache).toHaveBeenLastCalledWith("p1", payload);
|
||||
});
|
||||
|
||||
it("bridges authoritative oversight setting SSE once across duplicate consumers", async () => {
|
||||
const deps = makeDeps(() => Promise.resolve(makePayload()));
|
||||
renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps }));
|
||||
await waitFor(() => expect(deps.fetchBoardWorkflows).toHaveBeenCalledTimes(1));
|
||||
|
||||
const handler = subscribeHandlers["workflow:setting-values-updated"];
|
||||
expect(typeof handler).toBe("function");
|
||||
const event = new MessageEvent("workflow:setting-values-updated", {
|
||||
data: JSON.stringify({
|
||||
workflowId: "wf-a",
|
||||
projectId: "p1",
|
||||
settingIds: ["plannerOversightLevel"],
|
||||
mutationId: "revision-1",
|
||||
}),
|
||||
});
|
||||
act(() => {
|
||||
handler(event);
|
||||
handler(event);
|
||||
});
|
||||
|
||||
expect(getWorkflowSettingValuesRevision("wf-a", "p1")).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves the Board-only aggregate sentinel while resolving a concrete fallback workflow", async () => {
|
||||
localStorage.setItem("kb:p1:kb-dashboard-board-workflow-selection", ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
const deps = makeDeps(() => Promise.resolve(makePayload()));
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
removeBoardWorkflowSelection,
|
||||
writeBoardWorkflowSelection,
|
||||
} from "../utils/boardWorkflowSelection";
|
||||
import { notifyWorkflowSettingValuesUpdatedFromSse } from "../utils/workflowSettingValuesEvents";
|
||||
|
||||
/*
|
||||
FNXC:Workflows 2026-06-22-17:00:
|
||||
@@ -144,6 +145,13 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
|
||||
"workflow:created": forceRefreshBoardWorkflows,
|
||||
"workflow:updated": forceRefreshBoardWorkflows,
|
||||
"workflow:deleted": forceRefreshBoardWorkflows,
|
||||
"workflow:setting-values-updated": (event: MessageEvent) => {
|
||||
try {
|
||||
notifyWorkflowSettingValuesUpdatedFromSse(JSON.parse(event.data) as Record<string, unknown>);
|
||||
} catch {
|
||||
// Malformed SSE payloads are non-authoritative and cannot invalidate a card.
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
|
||||
59
packages/dashboard/app/utils/workflowSettingValuesEvents.ts
Normal file
59
packages/dashboard/app/utils/workflowSettingValuesEvents.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export const WORKFLOW_SETTING_VALUES_UPDATED_EVENT = "fusion:workflow-setting-values-updated";
|
||||
|
||||
export interface WorkflowSettingValuesUpdatedDetail {
|
||||
workflowId: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
const revisions = new Map<string, number>();
|
||||
const handledServerMutationIds = new Set<string>();
|
||||
const MAX_HANDLED_SERVER_MUTATIONS = 100;
|
||||
|
||||
export function getWorkflowSettingValuesKey(workflowId: string, projectId?: string): string {
|
||||
return `${projectId ?? "default"}::${workflowId}`;
|
||||
}
|
||||
|
||||
export function getWorkflowSettingValuesRevision(workflowId: string, projectId?: string): number {
|
||||
return revisions.get(getWorkflowSettingValuesKey(workflowId, projectId)) ?? 0;
|
||||
}
|
||||
|
||||
export function notifyWorkflowSettingValuesUpdated(workflowId: string, projectId?: string): void {
|
||||
const key = getWorkflowSettingValuesKey(workflowId, projectId);
|
||||
revisions.set(key, (revisions.get(key) ?? 0) + 1);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent<WorkflowSettingValuesUpdatedDetail>(WORKFLOW_SETTING_VALUES_UPDATED_EVENT, {
|
||||
detail: { workflowId, projectId },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkflowSettingValuesSsePayload {
|
||||
workflowId?: unknown;
|
||||
projectId?: unknown;
|
||||
settingIds?: unknown;
|
||||
mutationId?: unknown;
|
||||
}
|
||||
|
||||
/** FNXC:PlannerOversight 2026-07-18-13:35: Bridge an authoritative store/SSE mutation into the card-local revision event.
|
||||
* Duplicate dashboard consumers may receive the same multiplexed SSE message, so
|
||||
* mutation IDs are bounded and de-duplicated before advancing the revision. */
|
||||
export function notifyWorkflowSettingValuesUpdatedFromSse(payload: WorkflowSettingValuesSsePayload): void {
|
||||
if (typeof payload.workflowId !== "string" || typeof payload.mutationId !== "string") return;
|
||||
if (!Array.isArray(payload.settingIds) || !payload.settingIds.includes("plannerOversightLevel")) return;
|
||||
if (handledServerMutationIds.has(payload.mutationId)) return;
|
||||
handledServerMutationIds.add(payload.mutationId);
|
||||
if (handledServerMutationIds.size > MAX_HANDLED_SERVER_MUTATIONS) {
|
||||
const oldest = handledServerMutationIds.values().next().value;
|
||||
if (typeof oldest === "string") handledServerMutationIds.delete(oldest);
|
||||
}
|
||||
notifyWorkflowSettingValuesUpdated(
|
||||
payload.workflowId,
|
||||
typeof payload.projectId === "string" ? payload.projectId : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal Test helper */
|
||||
export function __test_clearWorkflowSettingValuesRevisions(): void {
|
||||
revisions.clear();
|
||||
handledServerMutationIds.clear();
|
||||
}
|
||||
@@ -570,3 +570,35 @@ describe("createSSE client cleanup", () => {
|
||||
expect(getActiveSSEConnections()).toBe(baseline);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow setting value SSE", () => {
|
||||
it("forwards only same-project workflow setting mutations and removes the listener on close", () => {
|
||||
const { req, res, store } = openSseConnection("workflow-settings-client", "project-1");
|
||||
const onCall = vi.mocked(store.on).mock.calls.find(([event]) => event === "workflow:setting-values-updated");
|
||||
const handler = onCall?.[1] as ((payload: unknown) => void) | undefined;
|
||||
|
||||
expect(typeof handler).toBe("function");
|
||||
const writeCountBeforeMutation = res.write.mock.calls.length;
|
||||
handler?.({
|
||||
workflowId: "builtin:coding",
|
||||
projectId: "project-2",
|
||||
settingIds: ["plannerOversightLevel"],
|
||||
mutationId: "other-project-revision",
|
||||
});
|
||||
|
||||
expect(res.write).toHaveBeenCalledTimes(writeCountBeforeMutation);
|
||||
|
||||
handler?.({
|
||||
workflowId: "builtin:coding",
|
||||
projectId: "project-1",
|
||||
settingIds: ["plannerOversightLevel"],
|
||||
mutationId: "revision-1",
|
||||
});
|
||||
|
||||
expect(res.write).toHaveBeenCalledWith(expect.stringContaining("event: workflow:setting-values-updated"));
|
||||
expect(res.write).toHaveBeenCalledWith(expect.stringContaining('"mutationId":"revision-1"'));
|
||||
|
||||
req.emit("close");
|
||||
expect(store.off).toHaveBeenCalledWith("workflow:setting-values-updated", handler);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -591,6 +591,15 @@ export function createSSE(
|
||||
};
|
||||
send(`event: agent:log\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
const onWorkflowSettingValuesUpdated = (data: {
|
||||
workflowId: string;
|
||||
projectId: string;
|
||||
settingIds: string[];
|
||||
mutationId: string;
|
||||
}) => {
|
||||
if (projectId && data.projectId !== projectId) return;
|
||||
send(`event: workflow:setting-values-updated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
const onArtifactRegistered = (artifact: unknown) => {
|
||||
/* FNXC:ArtifactRegistry 2026-06-27-00:00: Forward TaskStore's authoritative artifact registration event so live artifact surfaces refresh even when the best-effort inbox notification is absent or delayed. */
|
||||
@@ -907,6 +916,7 @@ export function createSSE(
|
||||
store.off("agent:log", onAgentLog);
|
||||
store.off("artifact:registered", onArtifactRegistered);
|
||||
store.off("artifact:updated", onArtifactUpdated);
|
||||
store.off("workflow:setting-values-updated", onWorkflowSettingValuesUpdated);
|
||||
if (missionStore) {
|
||||
missionStore.off("mission:created", onMissionCreated);
|
||||
missionStore.off("mission:updated", onMissionUpdated);
|
||||
@@ -1024,6 +1034,7 @@ export function createSSE(
|
||||
store.on("agent:log", onAgentLog);
|
||||
store.on("artifact:registered", onArtifactRegistered);
|
||||
store.on("artifact:updated", onArtifactUpdated);
|
||||
store.on("workflow:setting-values-updated", onWorkflowSettingValuesUpdated);
|
||||
|
||||
if (missionStore) {
|
||||
missionStore.on("mission:created", onMissionCreated);
|
||||
|
||||
Reference in New Issue
Block a user