From e5a0273282995ea2b56c8167129105ae1eb1d003 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 22:15:53 -0700 Subject: [PATCH] FN-7166: Refresh artifact lists from registration events Artifact list surfaces now refresh from authoritative registry and artifact-message signals.\n\n- Emit an artifact:registered TaskStore event after successful registry writes and forward it over dashboard SSE.\n- Subscribe useArtifacts to artifact/message SSE events, scope refreshes by project/task, debounce duplicate signals, and avoid loading flashes when project context is missing.\n- Document live artifact refresh behavior and add regression coverage for store events and hook refresh paths.\n\nFiles changed:\n .changeset/FN-7166-artifact-live-refresh.md | 7 +\n docs/dashboard-guide.md | 3 +-\n packages/core/src/__tests__/artifacts.test.ts | 20 +-\n packages/core/src/store.ts | 8 +-\n .../app/hooks/__tests__/useArtifacts.test.ts | 227 +++++++++++++++++++--\n packages/dashboard/app/hooks/useArtifacts.ts | 83 +++++++-\n packages/dashboard/src/sse.ts | 7 +\n 7 files changed, 335 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-7166 Fusion-Task-Lineage: f13e254d-d65c-423e-8d9c-94c31761eac2 Co-authored-by: Fusion (runfusion.ai) --- .changeset/FN-7166-artifact-live-refresh.md | 7 + docs/dashboard-guide.md | 3 +- packages/core/src/__tests__/artifacts.test.ts | 20 +- packages/core/src/store.ts | 8 +- .../app/hooks/__tests__/useArtifacts.test.ts | 227 ++++++++++++++++-- packages/dashboard/app/hooks/useArtifacts.ts | 83 ++++++- packages/dashboard/src/sse.ts | 7 + 7 files changed, 335 insertions(+), 20 deletions(-) create mode 100644 .changeset/FN-7166-artifact-live-refresh.md diff --git a/.changeset/FN-7166-artifact-live-refresh.md b/.changeset/FN-7166-artifact-live-refresh.md new file mode 100644 index 0000000000..a997bee5fb --- /dev/null +++ b/.changeset/FN-7166-artifact-live-refresh.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Artifact lists now refresh live when new artifacts are registered. +category: fix +dev: TaskStore emits artifact:registered SSE; useArtifacts also accepts message:sent/message:received and coalesces scoped refreshes. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index aef13d275e..959d28c0fd 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -597,6 +597,7 @@ Features: - Search documents across tasks - Open project markdown files with inline preview - Browse the **Artifacts** tab for registry media registered by any agent, dashboard chat/user action, or system tool across tasks +- Already-open global and task-detail artifact lists refresh live from the artifact registry event when an agent, dashboard chat session, user action, or system tool registers a new artifact, while preserving active search filters and task scoping - Use the tab-count badges to see the current counts for Project Files, Task Documents, and Artifacts; the Artifacts badge reflects the loaded `GET /api/artifacts` result set, including active search filters - Use the responsive media gallery to scan thumbnail-first image and video cards with consistent framing, while audio, document, and generic artifacts remain readable cards in the same grid - Expand image and video artifact thumbnails into a full-size lightbox; dismiss it with the close button, backdrop click, or Escape while non-previewable artifact cards keep their normal controls and links @@ -609,7 +610,7 @@ Features: - Toggle between raw text and rendered markdown using the **Markdown/Plain** button - Highlight text in raw or rendered project-file previews, choose **Add comment**, and send the file path, selected snippet, and your comment to the **New Task** dialog -Agent registrations also surface through the [Mailbox View](#mailbox-view): successful `fn_artifact_register` calls send a best-effort system inbox notification so users can discover new media even before opening the gallery. +Agent registrations also surface through the [Mailbox View](#mailbox-view): successful `fn_artifact_register` calls send a best-effort system inbox notification so users can discover new media even before opening the gallery. Artifact list live-refresh does not depend on that best-effort message; it listens to the registry registration event. ![Artifacts view](./screenshots/documents-view.png) diff --git a/packages/core/src/__tests__/artifacts.test.ts b/packages/core/src/__tests__/artifacts.test.ts index d86a4cd5d6..1b7e2e18c4 100644 --- a/packages/core/src/__tests__/artifacts.test.ts +++ b/packages/core/src/__tests__/artifacts.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { existsSync, mkdtempSync } from "node:fs"; import { readFile, rm } from "node:fs/promises"; import { join } from "node:path"; @@ -74,6 +74,24 @@ describe("TaskStore artifacts", () => { await expect(store.getArtifact("missing-artifact")).resolves.toBeNull(); }); + it("emits an authoritative event after artifact registration succeeds", async () => { + const task = await store.createTask({ title: "Artifact event task", description: "Emit artifact event" }); + const registered = vi.fn(); + store.on("artifact:registered", registered); + + const artifact = await store.registerArtifact({ + type: "document", + title: "Evented artifact", + content: "# Event", + authorId: "agent-alpha", + authorType: "agent", + taskId: task.id, + }); + + expect(registered).toHaveBeenCalledTimes(1); + expect(registered).toHaveBeenCalledWith(artifact); + }); + it("stores binary artifacts on disk under the task artifacts directory", async () => { const task = await store.createTask({ description: "Binary artifact task" }); const data = Buffer.from([0, 1, 2, 3, 255]); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index fec3670a94..8be7f30746 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1039,6 +1039,7 @@ export interface TaskStoreEvents { "task:deleted": [task: Task, meta?: { githubIssueAction?: GithubIssueAction }]; "task:merged": [result: MergeResult]; "settings:updated": [data: { settings: Settings; previous: Settings }]; + "artifact:registered": [artifact: Artifact]; "agent:log": [entry: AgentLogEntry]; "merger:autostashOrphans": [data: { rootDir: string; @@ -13097,6 +13098,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} /** * FNXC:ArtifactRegistry 2026-06-19-22:04: * Register multi-type agent/user/system artifacts in SQLite while writing binary payloads to disk. Task-scoped binaries use `.fusion/tasks/{taskId}/artifacts/`; task-less binaries use `.fusion/artifacts/`, and both store only a relative `artifacts/` uri in the row. + * + * FNXC:ArtifactRegistry 2026-06-27-00:00: + * Successful registry writes emit `artifact:registered` as the authoritative live-update signal. Dashboard inbox notifications remain best-effort discovery messages, so already-open artifact lists must not depend on message delivery to invalidate their SWR cache. */ async registerArtifact(input: ArtifactCreateInput): Promise { const id = randomUUID(); @@ -13129,7 +13133,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } }; - return input.taskId ? this.withTaskLock(input.taskId, register) : register(); + const artifact = input.taskId ? await this.withTaskLock(input.taskId, register) : await register(); + this.emit("artifact:registered", artifact); + return artifact; } /** diff --git a/packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts b/packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts index 28c3c384da..063d5f76ab 100644 --- a/packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts @@ -2,13 +2,28 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; import type { ArtifactWithTask } from "@fusion/core"; import { fetchArtifacts } from "../../api"; +import { subscribeSse } from "../../sse-bus"; import { useArtifacts } from "../useArtifacts"; +import { message } from "./sseTestHelpers"; + +const { handlers, unsubscribeMock } = vi.hoisted(() => ({ + handlers: {} as Record void>, + unsubscribeMock: vi.fn(), +})); vi.mock("../../api", () => ({ fetchArtifacts: vi.fn(), })); +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn((_url: string, opts: { events: Record void> }) => { + Object.assign(handlers, opts.events); + return unsubscribeMock; + }), +})); + const mockFetchArtifacts = vi.mocked(fetchArtifacts); +const mockSubscribeSse = vi.mocked(subscribeSse); const mockArtifacts: ArtifactWithTask[] = [ { @@ -23,10 +38,41 @@ const mockArtifacts: ArtifactWithTask[] = [ }, ]; +const newTaskArtifact: ArtifactWithTask = { + id: "artifact-2", + type: "document", + title: "Live note", + authorId: "agent-2", + authorType: "agent", + taskId: "FN-1", + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", +}; + +const loadInitialArtifacts = async () => { + await act(async () => { + await vi.runAllTimersAsync(); + }); +}; + +const fireArtifactRegistration = async ( + eventName: "artifact:registered" | "message:received" | "message:sent", + payload: object, +) => { + act(() => { + handlers[eventName]?.(message(payload)); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); +}; + describe("useArtifacts", () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); window.localStorage.clear(); + for (const key of Object.keys(handlers)) delete handlers[key]; + unsubscribeMock.mockClear(); mockFetchArtifacts.mockResolvedValue(mockArtifacts); }); @@ -42,9 +88,7 @@ describe("useArtifacts", () => { expect(result.current.loading).toBe(true); expect(result.current.artifacts).toEqual([]); - await act(async () => { - await vi.runAllTimersAsync(); - }); + await loadInitialArtifacts(); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.error).toBeNull(); @@ -61,9 +105,7 @@ describe("useArtifacts", () => { searchQuery: "demo", })); - await act(async () => { - await vi.runAllTimersAsync(); - }); + await loadInitialArtifacts(); expect(mockFetchArtifacts).toHaveBeenCalledWith({ type: "video", @@ -79,9 +121,7 @@ describe("useArtifacts", () => { { initialProps: { searchQuery: undefined as string | undefined } }, ); - await act(async () => { - await vi.runAllTimersAsync(); - }); + await loadInitialArtifacts(); mockFetchArtifacts.mockClear(); rerender({ searchQuery: "alpha" }); @@ -104,9 +144,7 @@ describe("useArtifacts", () => { mockFetchArtifacts.mockResolvedValueOnce(mockArtifacts); const { result } = renderHook(() => useArtifacts({ projectId: "project-4" })); - await act(async () => { - await vi.runAllTimersAsync(); - }); + await loadInitialArtifacts(); await waitFor(() => expect(result.current.artifacts).toEqual(mockArtifacts)); mockFetchArtifacts.mockRejectedValueOnce(new Error("Artifacts failed")); @@ -121,9 +159,7 @@ describe("useArtifacts", () => { it("refreshes artifacts on demand", async () => { const { result } = renderHook(() => useArtifacts({ projectId: "project-5" })); - await act(async () => { - await vi.runAllTimersAsync(); - }); + await loadInitialArtifacts(); mockFetchArtifacts.mockClear(); await act(async () => { @@ -133,4 +169,165 @@ describe("useArtifacts", () => { expect(mockFetchArtifacts).toHaveBeenCalledTimes(1); expect(mockFetchArtifacts).toHaveBeenCalledWith({ q: undefined }, "project-5"); }); + + it("refreshes task-scoped artifacts when a matching artifact registration arrives", async () => { + mockFetchArtifacts.mockResolvedValue([]); + const { result } = renderHook(() => useArtifacts({ projectId: "project-6", taskId: "FN-1" })); + + await loadInitialArtifacts(); + await waitFor(() => expect(result.current.artifacts).toEqual([])); + + mockFetchArtifacts.mockClear(); + mockFetchArtifacts.mockResolvedValue([newTaskArtifact]); + + await fireArtifactRegistration("message:received", { + metadata: { artifactId: newTaskArtifact.id, taskId: "FN-1" }, + }); + + await waitFor(() => expect(mockFetchArtifacts).toHaveBeenCalledTimes(1)); + expect(mockFetchArtifacts).toHaveBeenCalledWith({ taskId: "FN-1", q: undefined }, "project-6"); + await waitFor(() => expect(result.current.artifacts).toEqual([newTaskArtifact])); + }); + + it("does not refresh task-scoped artifacts for a different task", async () => { + renderHook(() => useArtifacts({ projectId: "project-7", taskId: "FN-1" })); + + await loadInitialArtifacts(); + mockFetchArtifacts.mockClear(); + + await fireArtifactRegistration("message:received", { + metadata: { artifactId: "artifact-other", taskId: "FN-2" }, + }); + + expect(mockFetchArtifacts).toHaveBeenCalledTimes(0); + }); + + it("refreshes task-scoped artifacts from the authoritative artifact registration event", async () => { + mockFetchArtifacts.mockResolvedValue([]); + const { result } = renderHook(() => useArtifacts({ projectId: "project-8a", taskId: "FN-1" })); + + await loadInitialArtifacts(); + await waitFor(() => expect(result.current.artifacts).toEqual([])); + + mockFetchArtifacts.mockClear(); + mockFetchArtifacts.mockResolvedValue([newTaskArtifact]); + + await fireArtifactRegistration("artifact:registered", { + id: newTaskArtifact.id, + taskId: "FN-1", + }); + + await waitFor(() => expect(mockFetchArtifacts).toHaveBeenCalledTimes(1)); + expect(mockFetchArtifacts).toHaveBeenCalledWith({ taskId: "FN-1", q: undefined }, "project-8a"); + await waitFor(() => expect(result.current.artifacts).toEqual([newTaskArtifact])); + }); + + it("refreshes project-scoped artifacts when any artifact registration arrives", async () => { + const updatedArtifacts = [...mockArtifacts, newTaskArtifact]; + const { result } = renderHook(() => useArtifacts({ projectId: "project-8" })); + + await loadInitialArtifacts(); + await waitFor(() => expect(result.current.artifacts).toEqual(mockArtifacts)); + + mockFetchArtifacts.mockClear(); + mockFetchArtifacts.mockResolvedValue(updatedArtifacts); + + await fireArtifactRegistration("message:sent", { + projectId: "project-8", + metadata: { artifactId: newTaskArtifact.id, taskId: "FN-1" }, + }); + + await waitFor(() => expect(mockFetchArtifacts).toHaveBeenCalledTimes(1)); + expect(mockFetchArtifacts).toHaveBeenCalledWith({ q: undefined }, "project-8"); + await waitFor(() => expect(result.current.artifacts).toEqual(updatedArtifacts)); + }); + + it("does not refresh project-scoped artifacts when the payload projectId mismatches", async () => { + renderHook(() => useArtifacts({ projectId: "project-9" })); + + await loadInitialArtifacts(); + mockFetchArtifacts.mockClear(); + + await fireArtifactRegistration("message:received", { + projectId: "project-other", + metadata: { artifactId: "artifact-other", taskId: "FN-1" }, + }); + + expect(mockFetchArtifacts).toHaveBeenCalledTimes(0); + }); + + it("ignores non-artifact messages", async () => { + renderHook(() => useArtifacts({ projectId: "project-10" })); + + await loadInitialArtifacts(); + mockFetchArtifacts.mockClear(); + + await fireArtifactRegistration("message:received", { + id: "message-not-artifact", + metadata: { taskId: "FN-1" }, + }); + + expect(mockFetchArtifacts).toHaveBeenCalledTimes(0); + }); + + it("ignores malformed message payloads", async () => { + renderHook(() => useArtifacts({ projectId: "project-11" })); + + await loadInitialArtifacts(); + mockFetchArtifacts.mockClear(); + + act(() => { + handlers["message:received"]?.({ data: "not-json" } as MessageEvent); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(mockFetchArtifacts).toHaveBeenCalledTimes(0); + }); + + it("coalesces duplicate sent and received artifact events into one refresh", async () => { + renderHook(() => useArtifacts({ projectId: "project-12", taskId: "FN-1" })); + + await loadInitialArtifacts(); + mockFetchArtifacts.mockClear(); + + act(() => { + const event = message({ metadata: { artifactId: "artifact-duplicate", taskId: "FN-1" } }); + handlers["message:received"]?.(event); + handlers["message:sent"]?.(event); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + await waitFor(() => expect(mockFetchArtifacts).toHaveBeenCalledTimes(1)); + }); + + it("subscribes to project-scoped artifact registration events and unsubscribes on unmount", async () => { + const { unmount } = renderHook(() => useArtifacts({ projectId: "project-13" })); + + expect(mockSubscribeSse).toHaveBeenCalledWith("/api/events?projectId=project-13", { + events: expect.objectContaining({ + "artifact:registered": expect.any(Function), + "message:received": expect.any(Function), + "message:sent": expect.any(Function), + }), + }); + + unmount(); + + expect(unsubscribeMock).toHaveBeenCalledTimes(1); + }); + + it("does not subscribe or fetch when no projectId is available", async () => { + const { result } = renderHook(() => useArtifacts()); + + await loadInitialArtifacts(); + + expect(result.current.loading).toBe(false); + expect(result.current.artifacts).toEqual([]); + expect(mockSubscribeSse).not.toHaveBeenCalled(); + expect(mockFetchArtifacts).not.toHaveBeenCalled(); + }); }); diff --git a/packages/dashboard/app/hooks/useArtifacts.ts b/packages/dashboard/app/hooks/useArtifacts.ts index cd971a0707..037fad3c32 100644 --- a/packages/dashboard/app/hooks/useArtifacts.ts +++ b/packages/dashboard/app/hooks/useArtifacts.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import type { ArtifactType, ArtifactWithTask } from "@fusion/core"; import { fetchArtifacts } from "../api"; +import { subscribeSse } from "../sse-bus"; import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache"; export interface UseArtifactsResult { @@ -40,13 +41,22 @@ export function useArtifacts(options?: { const cached = readCache(cacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS }); return Array.isArray(cached) ? cached : []; }); - const [loading, setLoading] = useState(() => artifacts.length === 0); + const [loading, setLoading] = useState(() => Boolean(projectId) && artifacts.length === 0); const [error, setError] = useState(null); const abortRef = useRef(null); const initialLoadCompleteRef = useRef(artifacts.length > 0); const debounceRef = useRef | null>(null); + const refreshRef = useRef<() => Promise>(async () => {}); + const sseRefreshDebounceRef = useRef | null>(null); const refresh = useCallback(async () => { + if (!projectId) { + setArtifacts([]); + setLoading(false); + initialLoadCompleteRef.current = false; + return; + } + if (abortRef.current) { abortRef.current.abort(); } @@ -90,11 +100,15 @@ export function useArtifacts(options?: { } }, [authorId, cacheKey, projectId, searchQuery, taskId, type]); + useEffect(() => { + refreshRef.current = refresh; + }, [refresh]); + useEffect(() => { if (!cacheKey) { initialLoadCompleteRef.current = false; setArtifacts([]); - setLoading(true); + setLoading(false); return; } @@ -126,6 +140,67 @@ export function useArtifacts(options?: { }; }, [refresh]); + useEffect(() => { + if (!cacheKey || !projectId) { + return; + } + + /* + * FNXC:ArtifactRegistry 2026-06-27-00:00: + * Already-open task and project artifact lists must live-refresh from TaskStore's authoritative artifact:registered SSE event and also accept the best-effort agent/chat message notifications as an additional signal. Task-scoped hooks filter by artifact/task metadata while project-scoped hooks rely on the projectId refetch and optional payload projectId guard, preserving SWR cached rendering, search filters, and scoped fetch behavior without showing a loading flash. + */ + const handleArtifactRegistration = (event: MessageEvent, source: "artifact" | "message") => { + try { + const payload = JSON.parse(event.data) as { + id?: string | null; + projectId?: string | null; + taskId?: string | null; + metadata?: { + artifactId?: string | null; + taskId?: string | null; + } | null; + }; + const artifactId = source === "artifact" ? payload.id : payload.metadata?.artifactId; + const artifactTaskId = source === "artifact" ? payload.taskId : payload.metadata?.taskId; + if (!artifactId) return; + if (payload.projectId && payload.projectId !== projectId) return; + if (taskId && artifactTaskId !== taskId) return; + + if (sseRefreshDebounceRef.current) { + return; + } + + sseRefreshDebounceRef.current = setTimeout(() => { + sseRefreshDebounceRef.current = null; + void refreshRef.current(); + }, 300); + } catch { + // no-op: malformed or non-JSON SSE payloads must not trigger artifact refetches. + } + }; + const handleAuthoritativeArtifact = (event: MessageEvent) => handleArtifactRegistration(event, "artifact"); + const handleArtifactMessage = (event: MessageEvent) => handleArtifactRegistration(event, "message"); + + const params = new URLSearchParams(); + params.set("projectId", projectId); + const query = params.size > 0 ? `?${params.toString()}` : ""; + const unsubscribe = subscribeSse(`/api/events${query}`, { + events: { + "artifact:registered": handleAuthoritativeArtifact, + "message:received": handleArtifactMessage, + "message:sent": handleArtifactMessage, + }, + }); + + return () => { + unsubscribe(); + if (sseRefreshDebounceRef.current) { + clearTimeout(sseRefreshDebounceRef.current); + sseRefreshDebounceRef.current = null; + } + }; + }, [cacheKey, projectId, taskId]); + useEffect(() => { void refresh(); @@ -133,6 +208,10 @@ export function useArtifacts(options?: { if (abortRef.current) { abortRef.current.abort(); } + if (sseRefreshDebounceRef.current) { + clearTimeout(sseRefreshDebounceRef.current); + sseRefreshDebounceRef.current = null; + } }; }, []); diff --git a/packages/dashboard/src/sse.ts b/packages/dashboard/src/sse.ts index 7f7111c3d6..f02b9f5775 100644 --- a/packages/dashboard/src/sse.ts +++ b/packages/dashboard/src/sse.ts @@ -531,6 +531,11 @@ export function createSSE( send(`event: task:merged\ndata: ${JSON.stringify(stripTaskEventHeavyFields(result))}\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. */ + send(`event: artifact:registered\ndata: ${JSON.stringify(artifact)}\n\n`); + }; + const onResearchRunCreated = (run: unknown) => { send(`event: research:run:created\ndata: ${JSON.stringify(run)}\n\n`); }; @@ -822,6 +827,7 @@ export function createSSE( store.off("task:updated", onUpdated); store.off("task:deleted", onDeleted); store.off("task:merged", onMerged); + store.off("artifact:registered", onArtifactRegistered); if (missionStore) { missionStore.off("mission:created", onMissionCreated); missionStore.off("mission:updated", onMissionUpdated); @@ -931,6 +937,7 @@ export function createSSE( store.on("task:updated", onUpdated); store.on("task:deleted", onDeleted); store.on("task:merged", onMerged); + store.on("artifact:registered", onArtifactRegistered); if (missionStore) { missionStore.on("mission:created", onMissionCreated);