From 776e90bf4895b0e291c7867e0345e99c17417ad3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 17 Jul 2026 11:42:58 -0700 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20wave9=20peels=20=E2=80=94=20tas?= =?UTF-8?q?k=20content,=20global/pi=20settings,=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-preserving package organization after #2252: - app/api/global-and-pi-settings.ts and task-content.ts from legacy.ts - types/messages.ts (ParticipantType + Message contracts) - merger-diff-volume-gate: resolveDiffVolumeGateSettings + formatFindings Ratchet legacy/types/merger line-count ceilings downward. --- packages/core/src/types.ts | 123 ++--- packages/core/src/types/messages.ts | 101 +++++ .../app/api/global-and-pi-settings.ts | 116 +++++ packages/dashboard/app/api/legacy.ts | 428 +++--------------- packages/dashboard/app/api/task-content.ts | 276 +++++++++++ .../engine/src/merger-diff-volume-gate.ts | 30 ++ packages/engine/src/merger.ts | 35 +- scripts/line-count-baseline.json | 6 +- 8 files changed, 619 insertions(+), 496 deletions(-) create mode 100644 packages/core/src/types/messages.ts create mode 100644 packages/dashboard/app/api/global-and-pi-settings.ts create mode 100644 packages/dashboard/app/api/task-content.ts diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ef5cb88d67..39be4ed936 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -6838,103 +6838,32 @@ export interface MigrationResult { // ── Messaging Types ────────────────────────────────────────────────────────── -/** Participant types for message routing */ -export type ParticipantType = "agent" | "user" | "system"; - -/** Canonical recipient ID for dashboard user mailbox routing. */ -export const DASHBOARD_USER_ID = "dashboard"; - -const DASHBOARD_USER_ALIASES = new Set([DASHBOARD_USER_ID, "user", "user:dashboard", "User: user:dashboard"]); - -/** Normalize participant identity for durable mailbox routing. */ -export function normalizeMessageParticipant(id: string, type: ParticipantType): { id: string; type: ParticipantType } { - if (type !== "user") { - return { id, type }; - } - - if (DASHBOARD_USER_ALIASES.has(id)) { - return { id: DASHBOARD_USER_ID, type }; - } - - return { id, type }; -} - -/** Message types/categories */ -export type MessageType = "agent-to-agent" | "agent-to-user" | "user-to-agent" | "system"; - -/** Stable metadata contract for linking a reply to an earlier message. */ -export interface MessageReplyReference { - /** ID of the message this one is replying to. */ - messageId: string; -} - -/** Optional metadata attached to mailbox messages. */ -export interface MessageMetadata extends Record { - /** Optional link to the original message when this message is a reply. */ - replyTo?: MessageReplyReference; - /** - * If true, the recipient agent is woken immediately on receipt regardless - * of their own `messageResponseMode` setting. Sender-initiated override — - * use sparingly for urgent messages. Ignored when recipient is a user. - */ - wakeRecipient?: boolean; -} - -/** Message record stored in the system */ -export interface Message { - /** Unique identifier */ - id: string; - /** Sender identifier */ - fromId: string; - /** Sender type */ - fromType: ParticipantType; - /** Recipient identifier */ - toId: string; - /** Recipient type */ - toType: ParticipantType; - /** Message body */ - content: string; - /** Message category */ - type: MessageType; - /** Whether the recipient has read this message */ - read: boolean; - /** Optional extra data */ - metadata?: MessageMetadata; - /** ISO-8601 timestamp of creation */ - createdAt: string; - /** ISO-8601 timestamp of last update */ - updatedAt: string; -} - -/** Input for creating a new message */ -export interface MessageCreateInput { - /** Sender identifier (auto-filled by the transport layer if omitted) */ - fromId?: string; - /** Sender type (auto-filled by the transport layer if omitted) */ - fromType?: ParticipantType; - /** Recipient identifier */ - toId: string; - /** Recipient type */ - toType: ParticipantType; - /** Message body */ - content: string; - /** Message category */ - type: MessageType; - /** Optional extra data */ - metadata?: MessageMetadata; -} - -/** Filter options for querying messages */ -export interface MessageFilter { - /** Filter by message type */ - type?: MessageType; - /** Filter by read status */ - read?: boolean; - /** Maximum number of messages to return */ - limit?: number; - /** Number of messages to skip (for pagination) */ - offset?: number; -} +import { + DASHBOARD_USER_ID, + normalizeMessageParticipant, +} from "./types/messages.js"; +export { + DASHBOARD_USER_ID, + normalizeMessageParticipant, +}; +import type { + ParticipantType, + MessageType, + MessageReplyReference, + MessageMetadata, + Message, + MessageCreateInput, + MessageFilter, +} from "./types/messages.js"; +export type { + ParticipantType, + MessageType, + MessageReplyReference, + MessageMetadata, + Message, + MessageCreateInput, + MessageFilter, +}; /** Validate mailbox metadata, including reply-link contract when present. */ export function validateMessageMetadata(metadata: MessageMetadata | undefined): void { diff --git a/packages/core/src/types/messages.ts b/packages/core/src/types/messages.ts new file mode 100644 index 0000000000..7c99a7e6fe --- /dev/null +++ b/packages/core/src/types/messages.ts @@ -0,0 +1,101 @@ +/** + * FNXC:CodeOrganization 2026-07-17-12:00: + * Messaging domain types peeled from types.ts. + */ + +export type ParticipantType = "agent" | "user" | "system"; + +/** Canonical recipient ID for dashboard user mailbox routing. */ +export const DASHBOARD_USER_ID = "dashboard"; + +const DASHBOARD_USER_ALIASES = new Set([DASHBOARD_USER_ID, "user", "user:dashboard", "User: user:dashboard"]); + +/** Normalize participant identity for durable mailbox routing. */ +export function normalizeMessageParticipant(id: string, type: ParticipantType): { id: string; type: ParticipantType } { + if (type !== "user") { + return { id, type }; + } + + if (DASHBOARD_USER_ALIASES.has(id)) { + return { id: DASHBOARD_USER_ID, type }; + } + + return { id, type }; +} + +/** Message types/categories */ +export type MessageType = "agent-to-agent" | "agent-to-user" | "user-to-agent" | "system"; + +/** Stable metadata contract for linking a reply to an earlier message. */ +export interface MessageReplyReference { + /** ID of the message this one is replying to. */ + messageId: string; +} + +/** Optional metadata attached to mailbox messages. */ +export interface MessageMetadata extends Record { + /** Optional link to the original message when this message is a reply. */ + replyTo?: MessageReplyReference; + /** + * If true, the recipient agent is woken immediately on receipt regardless + * of their own `messageResponseMode` setting. Sender-initiated override — + * use sparingly for urgent messages. Ignored when recipient is a user. + */ + wakeRecipient?: boolean; +} + +/** Message record stored in the system */ +export interface Message { + /** Unique identifier */ + id: string; + /** Sender identifier */ + fromId: string; + /** Sender type */ + fromType: ParticipantType; + /** Recipient identifier */ + toId: string; + /** Recipient type */ + toType: ParticipantType; + /** Message body */ + content: string; + /** Message category */ + type: MessageType; + /** Whether the recipient has read this message */ + read: boolean; + /** Optional extra data */ + metadata?: MessageMetadata; + /** ISO-8601 timestamp of creation */ + createdAt: string; + /** ISO-8601 timestamp of last update */ + updatedAt: string; +} + +/** Input for creating a new message */ +export interface MessageCreateInput { + /** Sender identifier (auto-filled by the transport layer if omitted) */ + fromId?: string; + /** Sender type (auto-filled by the transport layer if omitted) */ + fromType?: ParticipantType; + /** Recipient identifier */ + toId: string; + /** Recipient type */ + toType: ParticipantType; + /** Message body */ + content: string; + /** Message category */ + type: MessageType; + /** Optional extra data */ + metadata?: MessageMetadata; +} + +/** Filter options for querying messages */ +export interface MessageFilter { + /** Filter by message type */ + type?: MessageType; + /** Filter by read status */ + read?: boolean; + /** Maximum number of messages to return */ + limit?: number; + /** Number of messages to skip (for pagination) */ + offset?: number; +} diff --git a/packages/dashboard/app/api/global-and-pi-settings.ts b/packages/dashboard/app/api/global-and-pi-settings.ts new file mode 100644 index 0000000000..f9b5170651 --- /dev/null +++ b/packages/dashboard/app/api/global-and-pi-settings.ts @@ -0,0 +1,116 @@ +/** + * FNXC:CodeOrganization 2026-07-17-12:00: + * Global settings and pi-extension/package client API peeled from legacy.ts. + */ +import type { GlobalSettings, ProjectSettings, Settings } from "@fusion/core"; +import { api } from "./client.js"; +import type { FetchOptions } from "./client.js"; +import { withProjectId } from "./health.js"; +import { dedupe } from "./dedupe.js"; + +export function fetchGlobalSettings(options?: FetchOptions): Promise { + return dedupe("/settings/global", () => api("/settings/global"), options); +} + +/** Update global (user-level) settings. These persist across all fn projects. */ +export function updateGlobalSettings(settings: Partial): Promise { + return api("/settings/global", { + method: "PUT", + body: JSON.stringify(settings), + }); +} + +/** Fetch settings separated by scope: { global, project } */ +export function fetchSettingsByScope(projectId?: string): Promise<{ global: GlobalSettings; project: Partial }> { + return api<{ global: GlobalSettings; project: Partial }>(withProjectId("/settings/scopes", projectId)); +} + +export interface PiExtensionEntry { + id: string; + name: string; + path: string; + source: "fusion-global" | "pi-global" | "fusion-project" | "pi-project" | "package"; + enabled: boolean; +} + +export interface PiExtensionSettings { + extensions: PiExtensionEntry[]; + disabledIds: string[]; + settingsPath: string; +} + +export function fetchPiExtensions(projectId?: string): Promise { + return api(withProjectId("/settings/pi-extensions", projectId)); +} + +export function updatePiExtensions(disabledIds: string[], projectId?: string): Promise { + return api(withProjectId("/settings/pi-extensions", projectId), { + method: "PUT", + body: JSON.stringify({ disabledIds }), + }); +} + +/** + * Test a notification provider by sending a test notification. + * Supports "ntfy" and "webhook" provider IDs. + */ +export function testNotification(providerId: string, config?: Record, projectId?: string): Promise<{ success: boolean }> { + return api<{ success: boolean }>(withProjectId("/settings/test-notification", projectId), { + method: "POST", + body: JSON.stringify({ providerId, ...(config ?? {}) }), + }); +} + +/** + * Backward-compatible ntfy test helper. + * Wraps testNotification() while preserving the legacy function signature. + */ +export function testNtfyNotification( + config?: { + ntfyEnabled?: boolean; + ntfyTopic?: string; + ntfyBaseUrl?: string; + ntfyAccessToken?: string; + }, + projectId?: string, +): Promise<{ success: boolean }> { + return testNotification("ntfy", config as Record | undefined, projectId); +} + +/** Pi extension settings from ~/.pi/agent/settings.json (global scope) */ +export interface PiSettings { + packages: Array; + extensions: string[]; + skills: string[]; + prompts: string[]; + themes: string[]; +} + +/** Fetch pi extension settings (global scope from ~/.pi/agent/settings.json) */ +export function fetchPiSettings(): Promise { + return api("/pi-settings"); +} + +/** Update pi extension settings (partial update, global scope) */ +export async function updatePiSettings(settings: Partial): Promise<{ success: boolean }> { + return api<{ success: boolean }>("/pi-settings", { + method: "PUT", + body: JSON.stringify(settings), + }); +} + +/** Install a new pi package source (adds to ~/.pi/agent/settings.json) */ +export async function installPiPackage(source: string): Promise<{ success: boolean }> { + return api<{ success: boolean }>("/pi-settings/packages", { + method: "POST", + body: JSON.stringify({ source }), + }); +} + +/** Reinstall Fusion's bundled pi package and ensure it remains in global Pi settings. */ +export async function reinstallFusionPiPackage(projectId?: string): Promise<{ success: boolean; source: string }> { + return api<{ success: boolean; source: string }>(withProjectId("/pi-settings/reinstall-fusion", projectId), { + method: "POST", + }); +} + diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index b6d9bed007..a7dbc9e39c 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -2,10 +2,7 @@ import type { Task, TaskDetail, TaskReviewData, - TaskAttachment, - TaskComment, AgentLogEntry, - Settings, GlobalSettings, ProjectSettings, BatchStatusResult, @@ -20,12 +17,6 @@ import type { PluginUiSlotDefinition, PluginUiContributionDefinition, PluginDashboardViewDefinition, - TaskDocument, - TaskDocumentRevision, - TaskDocumentWithTask, - Artifact, - ArtifactType, - ArtifactWithTask, Message, MessageMetadata, @@ -223,6 +214,63 @@ export { } from "./settings.js"; export type { UpdateInstallResponse } from "./settings.js"; +/* + * FNXC:CodeOrganization 2026-07-17-12:00: + * Preserve legacy global/pi settings and task-content imports via satellites. + */ +export { + fetchGlobalSettings, + updateGlobalSettings, + fetchSettingsByScope, + fetchPiExtensions, + updatePiExtensions, + testNotification, + testNtfyNotification, + fetchPiSettings, + updatePiSettings, + installPiPackage, + reinstallFusionPiPackage, +} from "./global-and-pi-settings.js"; +export type { + PiExtensionEntry, + PiExtensionSettings, + PiSettings, +} from "./global-and-pi-settings.js"; + +export { + uploadAttachment, + deleteAttachment, + fetchAgentLogs, + fetchAgentLogsWithMeta, + fetchSessionFiles, + fetchTaskComments, + addTaskComment, + updateTaskComment, + deleteTaskComment, + fetchTaskDocuments, + fetchTaskDocument, + fetchTaskDocumentRevisions, + fetchArtifacts, + artifactMediaUrl, + artifactMediaUrlWithToken, + fetchArtifact, + updateArtifact, + fetchAllDocuments, + fetchProjectMarkdownFiles, + putTaskDocument, + deleteTaskDocument, +} from "./task-content.js"; +export type { + FetchAllDocumentsOptions, + MarkdownFileEntry, + MarkdownFileListResponse, + FetchArtifactsOptions, + UpdateArtifactInput, +} from "./task-content.js"; +// Artifact types still re-exported from core for callers of legacy barrel +export type { Artifact, ArtifactType, ArtifactWithTask } from "@fusion/core"; + + /* * FNXC:CodeOrganization 2026-07-16-20:00: * Preserve legacy board/remote/memory imports while implementations live in satellites. @@ -320,368 +368,6 @@ export type { SkillFileContent, }; -export function fetchGlobalSettings(options?: FetchOptions): Promise { - return dedupe("/settings/global", () => api("/settings/global"), options); -} - -/** Update global (user-level) settings. These persist across all fn projects. */ -export function updateGlobalSettings(settings: Partial): Promise { - return api("/settings/global", { - method: "PUT", - body: JSON.stringify(settings), - }); -} - -/** Fetch settings separated by scope: { global, project } */ -export function fetchSettingsByScope(projectId?: string): Promise<{ global: GlobalSettings; project: Partial }> { - return api<{ global: GlobalSettings; project: Partial }>(withProjectId("/settings/scopes", projectId)); -} - -export interface PiExtensionEntry { - id: string; - name: string; - path: string; - source: "fusion-global" | "pi-global" | "fusion-project" | "pi-project" | "package"; - enabled: boolean; -} - -export interface PiExtensionSettings { - extensions: PiExtensionEntry[]; - disabledIds: string[]; - settingsPath: string; -} - -export function fetchPiExtensions(projectId?: string): Promise { - return api(withProjectId("/settings/pi-extensions", projectId)); -} - -export function updatePiExtensions(disabledIds: string[], projectId?: string): Promise { - return api(withProjectId("/settings/pi-extensions", projectId), { - method: "PUT", - body: JSON.stringify({ disabledIds }), - }); -} - -/** - * Test a notification provider by sending a test notification. - * Supports "ntfy" and "webhook" provider IDs. - */ -export function testNotification(providerId: string, config?: Record, projectId?: string): Promise<{ success: boolean }> { - return api<{ success: boolean }>(withProjectId("/settings/test-notification", projectId), { - method: "POST", - body: JSON.stringify({ providerId, ...(config ?? {}) }), - }); -} - -/** - * Backward-compatible ntfy test helper. - * Wraps testNotification() while preserving the legacy function signature. - */ -export function testNtfyNotification( - config?: { - ntfyEnabled?: boolean; - ntfyTopic?: string; - ntfyBaseUrl?: string; - ntfyAccessToken?: string; - }, - projectId?: string, -): Promise<{ success: boolean }> { - return testNotification("ntfy", config as Record | undefined, projectId); -} - -/** Pi extension settings from ~/.pi/agent/settings.json (global scope) */ -export interface PiSettings { - packages: Array; - extensions: string[]; - skills: string[]; - prompts: string[]; - themes: string[]; -} - -/** Fetch pi extension settings (global scope from ~/.pi/agent/settings.json) */ -export function fetchPiSettings(): Promise { - return api("/pi-settings"); -} - -/** Update pi extension settings (partial update, global scope) */ -export async function updatePiSettings(settings: Partial): Promise<{ success: boolean }> { - return api<{ success: boolean }>("/pi-settings", { - method: "PUT", - body: JSON.stringify(settings), - }); -} - -/** Install a new pi package source (adds to ~/.pi/agent/settings.json) */ -export async function installPiPackage(source: string): Promise<{ success: boolean }> { - return api<{ success: boolean }>("/pi-settings/packages", { - method: "POST", - body: JSON.stringify({ source }), - }); -} - -/** Reinstall Fusion's bundled pi package and ensure it remains in global Pi settings. */ -export async function reinstallFusionPiPackage(projectId?: string): Promise<{ success: boolean; source: string }> { - return api<{ success: boolean; source: string }>(withProjectId("/pi-settings/reinstall-fusion", projectId), { - method: "POST", - }); -} - -export async function uploadAttachment(id: string, file: File, projectId?: string): Promise { - const formData = new FormData(); - formData.append("file", file); - const res = await fetch(buildApiUrl(withProjectId(`/tasks/${id}/attachments`, projectId)), { - method: "POST", - headers: withTokenHeader(), - body: formData, - }); - const data = await res.json(); - if (!res.ok) throw new Error((data as { error?: string }).error || "Upload failed"); - return data as TaskAttachment; -} - -export async function deleteAttachment(id: string, filename: string, projectId?: string): Promise { - return api(withProjectId(`/tasks/${id}/attachments/${filename}`, projectId), { method: "DELETE" }); -} - -export function fetchAgentLogs( - taskId: string, - projectId?: string, - options?: { limit?: number; offset?: number }, -): Promise { - const params = new URLSearchParams(); - if (options?.limit !== undefined) { - params.set("limit", String(options.limit)); - } - if (options?.offset !== undefined) { - params.set("offset", String(options.offset)); - } - const suffix = params.toString() ? `?${params.toString()}` : ""; - return api(withProjectId(`/tasks/${taskId}/logs${suffix}`, projectId)); -} - -/** - * Fetch agent logs with pagination metadata. - * Returns entries along with total count and hasMore flag from response headers. - */ -export async function fetchAgentLogsWithMeta( - taskId: string, - projectId?: string, - options?: { limit?: number; offset?: number }, -): Promise<{ entries: AgentLogEntry[]; total: number; hasMore: boolean }> { - const params = new URLSearchParams(); - if (options?.limit !== undefined) { - params.set("limit", String(options.limit)); - } - if (options?.offset !== undefined) { - params.set("offset", String(options.offset)); - } - const suffix = params.toString() ? `?${params.toString()}` : ""; - const url = withProjectId(`/tasks/${taskId}/logs${suffix}`, projectId); - - const response = await fetch(buildApiUrl(url), { - headers: withTokenHeader(), - }); - - if (!response.ok) { - const data = await response.json().catch(() => ({ error: "Failed to fetch agent logs" })); - throw new Error((data as { error?: string }).error || `HTTP ${response.status}`); - } - - const entries = await response.json() as AgentLogEntry[]; - - // Read pagination headers - const total = response.headers.has("X-Total-Count") - ? parseInt(response.headers.get("X-Total-Count")!, 10) - : entries.length; - const hasMore = response.headers.has("X-Has-More") - ? response.headers.get("X-Has-More") === "true" - : false; - - return { entries, total, hasMore }; -} - -export function fetchSessionFiles(taskId: string, projectId?: string): Promise { - return api(withProjectId(`/tasks/${taskId}/session-files`, projectId)); -} - -export function fetchTaskComments(id: string, projectId?: string): Promise { - return api(withProjectId(`/tasks/${id}/comments`, projectId)); -} - -export function addTaskComment(id: string, text: string, author?: string, projectId?: string): Promise { - return api(withProjectId(`/tasks/${id}/comments`, projectId), { - method: "POST", - body: JSON.stringify({ text, author }), - }); -} - -export function updateTaskComment(id: string, commentId: string, text: string, projectId?: string): Promise { - return api(withProjectId(`/tasks/${id}/comments/${commentId}`, projectId), { - method: "PATCH", - body: JSON.stringify({ text }), - }); -} - -export function deleteTaskComment(id: string, commentId: string, projectId?: string): Promise { - return api(withProjectId(`/tasks/${id}/comments/${commentId}`, projectId), { - method: "DELETE", - }); -} - -// ── Task Document API Functions ────────────────────────────────────────────── - -export function fetchTaskDocuments(taskId: string, projectId?: string): Promise { - return api(withProjectId(`/tasks/${taskId}/documents`, projectId)); -} - -export function fetchTaskDocument(taskId: string, key: string, projectId?: string): Promise { - return api(withProjectId(`/tasks/${taskId}/documents/${key}`, projectId)); -} - -export function fetchTaskDocumentRevisions(taskId: string, key: string, projectId?: string): Promise { - return api(withProjectId(`/tasks/${taskId}/documents/${key}/revisions`, projectId)); -} - -export interface FetchAllDocumentsOptions { - q?: string; - limit?: number; - offset?: number; -} - -export interface MarkdownFileEntry { - path: string; - name: string; - size: number; - mtime: string; -} - -export interface MarkdownFileListResponse { - files: MarkdownFileEntry[]; -} - -export type { Artifact, ArtifactType, ArtifactWithTask }; - -export interface FetchArtifactsOptions { - type?: ArtifactType; - authorId?: string; - taskId?: string; - q?: string; - limit?: number; - offset?: number; -} - -export async function fetchArtifacts( - options?: FetchArtifactsOptions, - projectId?: string, -): Promise { - const params = new URLSearchParams(); - if (options?.type) params.set("type", options.type); - if (options?.authorId) params.set("authorId", options.authorId); - if (options?.taskId) params.set("taskId", options.taskId); - if (options?.q) params.set("q", options.q); - if (options?.limit !== undefined) params.set("limit", String(options.limit)); - if (options?.offset !== undefined) params.set("offset", String(options.offset)); - const queryString = params.toString(); - const path = `/artifacts${queryString ? `?${queryString}` : ""}`; - return api(withProjectId(path, projectId)); -} - -/* -FNXC:ArtifactRegistry 2026-07-15-12:00: -Keep artifactMediaUrl token-free so fetch callers and script-capable HTML previews can authenticate via Authorization without putting the daemon token into a URL that executable artifact content could read. - -FNXC:ArtifactMediaAuth 2026-07-15-14:24: -Main previously always-tokenized this helper for browser-native media loads. FN-7976 supersedes that by splitting tokenized element/link loads into artifactMediaUrlWithToken while this base URL stays clean for header-auth fetch and HTML blob previews. -*/ -export function artifactMediaUrl(id: string, projectId?: string): string { - return buildApiUrl(withProjectId(`/artifacts/${encodeURIComponent(id)}/media`, projectId)); -} - -/* -FNXC:ArtifactRegistry 2026-07-15-12:00: -Artifact media element loads and link navigations cannot attach an Authorization header, so authenticated daemon media routes require the dashboard-owned fn_token query fallback. Keep artifactMediaUrl token-free for fetch callers and script-capable HTML previews; consumers that hand the URL to an img, video, audio, iframe, or anchor must use this helper unless executable content could read the URL. -*/ -export function artifactMediaUrlWithToken(id: string, projectId?: string): string { - return appendTokenQuery(artifactMediaUrl(id, projectId)); -} - -/* -FNXC:ArtifactRegistry 2026-07-10-15:20: -The Artifacts view document viewer needs the full artifact INCLUDING inline content (list responses strip content), and edit mode persists title/description/content through PATCH. -*/ -export async function fetchArtifact(id: string, projectId?: string): Promise { - return api(withProjectId(`/artifacts/${encodeURIComponent(id)}`, projectId)); -} - -export interface UpdateArtifactInput { - title?: string; - description?: string; - content?: string; -} - -export async function updateArtifact(id: string, updates: UpdateArtifactInput, projectId?: string): Promise { - return api(withProjectId(`/artifacts/${encodeURIComponent(id)}`, projectId), { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updates), - }); -} - -export async function fetchAllDocuments( - options?: FetchAllDocumentsOptions, - projectId?: string, -): Promise { - const params = new URLSearchParams(); - if (options?.q) params.set("q", options.q); - if (options?.limit !== undefined) params.set("limit", String(options.limit)); - if (options?.offset !== undefined) params.set("offset", String(options.offset)); - const queryString = params.toString(); - const path = `/documents${queryString ? `?${queryString}` : ""}`; - return api(withProjectId(path, projectId)); -} - -export interface FetchProjectMarkdownFilesOptions { - showHidden?: boolean; -} - -export function fetchProjectMarkdownFiles( - projectId?: string, - options?: FetchProjectMarkdownFilesOptions, -): Promise { - const params = new URLSearchParams(); - if (options?.showHidden) { - params.set("showHidden", "1"); - } - - const query = params.toString(); - const path = `/files/markdown-list${query ? `?${query}` : ""}`; - - return api(withProjectId(path, projectId)); -} - -export function putTaskDocument( - taskId: string, - key: string, - content: string, - opts?: { author?: string; metadata?: Record }, - projectId?: string, -): Promise { - return api(withProjectId(`/tasks/${taskId}/documents/${key}`, projectId), { - method: "PUT", - body: JSON.stringify({ - content, - author: opts?.author, - metadata: opts?.metadata, - }), - }); -} - -export function deleteTaskDocument(taskId: string, key: string, projectId?: string): Promise { - return api(withProjectId(`/tasks/${taskId}/documents/${key}`, projectId), { - method: "DELETE", - }); -} - export function addSteeringComment(id: string, text: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/steer`, projectId), { method: "POST", diff --git a/packages/dashboard/app/api/task-content.ts b/packages/dashboard/app/api/task-content.ts new file mode 100644 index 0000000000..f2298fb233 --- /dev/null +++ b/packages/dashboard/app/api/task-content.ts @@ -0,0 +1,276 @@ +/** + * FNXC:CodeOrganization 2026-07-17-12:00: + * Task attachments, logs, comments, documents, and artifacts client API peeled from legacy.ts. + */ +import type { + Task, + TaskAttachment, + TaskComment, + TaskDocument, + TaskDocumentRevision, + TaskDocumentWithTask, + Artifact, + ArtifactType, + ArtifactWithTask, + AgentLogEntry, +} from "@fusion/core"; +import { appendTokenQuery, withTokenHeader } from "../auth"; +import { api, buildApiUrl } from "./client.js"; +import { withProjectId } from "./health.js"; + +export async function uploadAttachment(id: string, file: File, projectId?: string): Promise { + const formData = new FormData(); + formData.append("file", file); + const res = await fetch(buildApiUrl(withProjectId(`/tasks/${id}/attachments`, projectId)), { + method: "POST", + headers: withTokenHeader(), + body: formData, + }); + const data = await res.json(); + if (!res.ok) throw new Error((data as { error?: string }).error || "Upload failed"); + return data as TaskAttachment; +} + +export async function deleteAttachment(id: string, filename: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${id}/attachments/${filename}`, projectId), { method: "DELETE" }); +} + +export function fetchAgentLogs( + taskId: string, + projectId?: string, + options?: { limit?: number; offset?: number }, +): Promise { + const params = new URLSearchParams(); + if (options?.limit !== undefined) { + params.set("limit", String(options.limit)); + } + if (options?.offset !== undefined) { + params.set("offset", String(options.offset)); + } + const suffix = params.toString() ? `?${params.toString()}` : ""; + return api(withProjectId(`/tasks/${taskId}/logs${suffix}`, projectId)); +} + +/** + * Fetch agent logs with pagination metadata. + * Returns entries along with total count and hasMore flag from response headers. + */ +export async function fetchAgentLogsWithMeta( + taskId: string, + projectId?: string, + options?: { limit?: number; offset?: number }, +): Promise<{ entries: AgentLogEntry[]; total: number; hasMore: boolean }> { + const params = new URLSearchParams(); + if (options?.limit !== undefined) { + params.set("limit", String(options.limit)); + } + if (options?.offset !== undefined) { + params.set("offset", String(options.offset)); + } + const suffix = params.toString() ? `?${params.toString()}` : ""; + const url = withProjectId(`/tasks/${taskId}/logs${suffix}`, projectId); + + const response = await fetch(buildApiUrl(url), { + headers: withTokenHeader(), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({ error: "Failed to fetch agent logs" })); + throw new Error((data as { error?: string }).error || `HTTP ${response.status}`); + } + + const entries = await response.json() as AgentLogEntry[]; + + // Read pagination headers + const total = response.headers.has("X-Total-Count") + ? parseInt(response.headers.get("X-Total-Count")!, 10) + : entries.length; + const hasMore = response.headers.has("X-Has-More") + ? response.headers.get("X-Has-More") === "true" + : false; + + return { entries, total, hasMore }; +} + +export function fetchSessionFiles(taskId: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${taskId}/session-files`, projectId)); +} + +export function fetchTaskComments(id: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${id}/comments`, projectId)); +} + +export function addTaskComment(id: string, text: string, author?: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${id}/comments`, projectId), { + method: "POST", + body: JSON.stringify({ text, author }), + }); +} + +export function updateTaskComment(id: string, commentId: string, text: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${id}/comments/${commentId}`, projectId), { + method: "PATCH", + body: JSON.stringify({ text }), + }); +} + +export function deleteTaskComment(id: string, commentId: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${id}/comments/${commentId}`, projectId), { + method: "DELETE", + }); +} + +// ── Task Document API Functions ────────────────────────────────────────────── + +export function fetchTaskDocuments(taskId: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${taskId}/documents`, projectId)); +} + +export function fetchTaskDocument(taskId: string, key: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${taskId}/documents/${key}`, projectId)); +} + +export function fetchTaskDocumentRevisions(taskId: string, key: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${taskId}/documents/${key}/revisions`, projectId)); +} + +export interface FetchAllDocumentsOptions { + q?: string; + limit?: number; + offset?: number; +} + +export interface MarkdownFileEntry { + path: string; + name: string; + size: number; + mtime: string; +} + +export interface MarkdownFileListResponse { + files: MarkdownFileEntry[]; +} + +export type { Artifact, ArtifactType, ArtifactWithTask }; + +export interface FetchArtifactsOptions { + type?: ArtifactType; + authorId?: string; + taskId?: string; + q?: string; + limit?: number; + offset?: number; +} + +export async function fetchArtifacts( + options?: FetchArtifactsOptions, + projectId?: string, +): Promise { + const params = new URLSearchParams(); + if (options?.type) params.set("type", options.type); + if (options?.authorId) params.set("authorId", options.authorId); + if (options?.taskId) params.set("taskId", options.taskId); + if (options?.q) params.set("q", options.q); + if (options?.limit !== undefined) params.set("limit", String(options.limit)); + if (options?.offset !== undefined) params.set("offset", String(options.offset)); + const queryString = params.toString(); + const path = `/artifacts${queryString ? `?${queryString}` : ""}`; + return api(withProjectId(path, projectId)); +} + +/* +FNXC:ArtifactRegistry 2026-07-15-12:00: +Keep artifactMediaUrl token-free so fetch callers and script-capable HTML previews can authenticate via Authorization without putting the daemon token into a URL that executable artifact content could read. + +FNXC:ArtifactMediaAuth 2026-07-15-14:24: +Main previously always-tokenized this helper for browser-native media loads. FN-7976 supersedes that by splitting tokenized element/link loads into artifactMediaUrlWithToken while this base URL stays clean for header-auth fetch and HTML blob previews. +*/ +export function artifactMediaUrl(id: string, projectId?: string): string { + return buildApiUrl(withProjectId(`/artifacts/${encodeURIComponent(id)}/media`, projectId)); +} + +/* +FNXC:ArtifactRegistry 2026-07-15-12:00: +Artifact media element loads and link navigations cannot attach an Authorization header, so authenticated daemon media routes require the dashboard-owned fn_token query fallback. Keep artifactMediaUrl token-free for fetch callers and script-capable HTML previews; consumers that hand the URL to an img, video, audio, iframe, or anchor must use this helper unless executable content could read the URL. +*/ +export function artifactMediaUrlWithToken(id: string, projectId?: string): string { + return appendTokenQuery(artifactMediaUrl(id, projectId)); +} + +/* +FNXC:ArtifactRegistry 2026-07-10-15:20: +The Artifacts view document viewer needs the full artifact INCLUDING inline content (list responses strip content), and edit mode persists title/description/content through PATCH. +*/ +export async function fetchArtifact(id: string, projectId?: string): Promise { + return api(withProjectId(`/artifacts/${encodeURIComponent(id)}`, projectId)); +} + +export interface UpdateArtifactInput { + title?: string; + description?: string; + content?: string; +} + +export async function updateArtifact(id: string, updates: UpdateArtifactInput, projectId?: string): Promise { + return api(withProjectId(`/artifacts/${encodeURIComponent(id)}`, projectId), { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }); +} + +export async function fetchAllDocuments( + options?: FetchAllDocumentsOptions, + projectId?: string, +): Promise { + const params = new URLSearchParams(); + if (options?.q) params.set("q", options.q); + if (options?.limit !== undefined) params.set("limit", String(options.limit)); + if (options?.offset !== undefined) params.set("offset", String(options.offset)); + const queryString = params.toString(); + const path = `/documents${queryString ? `?${queryString}` : ""}`; + return api(withProjectId(path, projectId)); +} + +export interface FetchProjectMarkdownFilesOptions { + showHidden?: boolean; +} + +export function fetchProjectMarkdownFiles( + projectId?: string, + options?: FetchProjectMarkdownFilesOptions, +): Promise { + const params = new URLSearchParams(); + if (options?.showHidden) { + params.set("showHidden", "1"); + } + + const query = params.toString(); + const path = `/files/markdown-list${query ? `?${query}` : ""}`; + + return api(withProjectId(path, projectId)); +} + +export function putTaskDocument( + taskId: string, + key: string, + content: string, + opts?: { author?: string; metadata?: Record }, + projectId?: string, +): Promise { + return api(withProjectId(`/tasks/${taskId}/documents/${key}`, projectId), { + method: "PUT", + body: JSON.stringify({ + content, + author: opts?.author, + metadata: opts?.metadata, + }), + }); +} + +export function deleteTaskDocument(taskId: string, key: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${taskId}/documents/${key}`, projectId), { + method: "DELETE", + }); +} + diff --git a/packages/engine/src/merger-diff-volume-gate.ts b/packages/engine/src/merger-diff-volume-gate.ts index 2c16370066..9b04b9c53f 100644 --- a/packages/engine/src/merger-diff-volume-gate.ts +++ b/packages/engine/src/merger-diff-volume-gate.ts @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import type { Settings } from "@fusion/core"; import { GENERATED_PATTERNS, LOCKFILE_PATTERNS, matchGlob } from "./merger.js"; const execFileAsync = promisify(execFile); @@ -101,3 +102,32 @@ export async function checkDiffVolume({ throw new DiffVolumeRegressionError(findings); } } + +export interface DiffVolumeGateSettings { + minLines: number; + threshold: number; + allowlistGlobs: string[]; +} + +/* + * FNXC:CodeOrganization 2026-07-17-12:00: + * Diff-volume settings normalization and finding formatting peeled from merger.ts. + */ +export function resolveDiffVolumeGateSettings(settings?: Settings): DiffVolumeGateSettings { + const minLinesRaw = settings?.mergeDiffVolumeMinLines ?? 20; + const thresholdRaw = settings?.mergeDiffVolumeThreshold ?? 0.2; + return { + minLines: Math.max(1, Math.trunc(Number.isFinite(minLinesRaw) ? minLinesRaw : 20)), + threshold: Math.min(1, Math.max(0, Number.isFinite(thresholdRaw) ? thresholdRaw : 0.2)), + allowlistGlobs: Array.isArray(settings?.mergeDiffVolumeAllowlist) + ? settings.mergeDiffVolumeAllowlist.filter((glob): glob is string => typeof glob === "string" && glob.trim().length > 0) + : [], + }; +} + +export function formatDiffVolumeFindings(findings: ReadonlyArray<{ file: string; branchNet: number; staged: number; ratio: number }>): string { + return findings + .map((finding) => `${finding.file} (branchNet=${finding.branchNet}, staged=${finding.staged}, ratio=${finding.ratio.toFixed(3)})`) + .join("\n"); +} + diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 89b529a58b..66d0dc4c1f 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -261,7 +261,16 @@ import { type SquashAuditFindings, } from "./merger-squash-audit.js"; import { detectMergeOverlap, restoreBranchWinsFiles } from "./merger-overlap-guard.js"; -import { checkDiffVolume, DiffVolumeRegressionError } from "./merger-diff-volume-gate.js"; +import { + checkDiffVolume, + DiffVolumeRegressionError, + resolveDiffVolumeGateSettings, + formatDiffVolumeFindings, +} from "./merger-diff-volume-gate.js"; +export { + resolveDiffVolumeGateSettings, + formatDiffVolumeFindings, +} from "./merger-diff-volume-gate.js"; import { detectAlreadyLandedOnMain, type AlreadyMergedDetectionStrategy } from "./already-merged-detector.js"; import { decideAutoPrerebase, probeDivergence, runAutoPrerebase } from "./merger-auto-prerebase.js"; import { @@ -498,30 +507,6 @@ const MERGE_USER_COMMENTS_MAX_CHARS = 4000; export const summarizeVerificationOutputLocal = summarizeVerificationOutput; -interface DiffVolumeGateSettings { - minLines: number; - threshold: number; - allowlistGlobs: string[]; -} - -function resolveDiffVolumeGateSettings(settings?: Settings): DiffVolumeGateSettings { - const minLinesRaw = settings?.mergeDiffVolumeMinLines ?? 20; - const thresholdRaw = settings?.mergeDiffVolumeThreshold ?? 0.2; - return { - minLines: Math.max(1, Math.trunc(Number.isFinite(minLinesRaw) ? minLinesRaw : 20)), - threshold: Math.min(1, Math.max(0, Number.isFinite(thresholdRaw) ? thresholdRaw : 0.2)), - allowlistGlobs: Array.isArray(settings?.mergeDiffVolumeAllowlist) - ? settings.mergeDiffVolumeAllowlist.filter((glob): glob is string => typeof glob === "string" && glob.trim().length > 0) - : [], - }; -} - -function formatDiffVolumeFindings(findings: ReadonlyArray<{ file: string; branchNet: number; staged: number; ratio: number }>): string { - return findings - .map((finding) => `${finding.file} (branchNet=${finding.branchNet}, staged=${finding.staged}, ratio=${finding.ratio.toFixed(3)})`) - .join("\n"); -} - async function resetToIntegrationTarget(rootDir: string, integrationTargetSha: string): Promise { await execAsync(`git reset --hard ${quoteArg(integrationTargetSha)}`, { cwd: rootDir, diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json index 0c0e1e15a0..5bdb64b04e 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -16,8 +16,8 @@ "packages/core/src/mission-store.ts": 4364, "packages/core/src/postgres/sqlite-migrator.ts": 2068, "packages/core/src/store.ts": 2631, - "packages/core/src/types.ts": 6928, - "packages/dashboard/app/api/legacy.ts": 10561, + "packages/core/src/types.ts": 6937, + "packages/dashboard/app/api/legacy.ts": 10260, "packages/dashboard/app/components/AgentDetailView.tsx": 5552, "packages/dashboard/app/components/AgentsView.tsx": 2254, "packages/dashboard/app/components/ChatView.tsx": 3850, @@ -92,7 +92,7 @@ "packages/engine/src/agent-tools.ts": 4793, "packages/engine/src/executor.ts": 19107, "packages/engine/src/merger-ai.ts": 2148, - "packages/engine/src/merger.ts": 11218, + "packages/engine/src/merger.ts": 11208, "packages/engine/src/pi.ts": 2799, "packages/engine/src/project-engine.ts": 5051, "packages/engine/src/scheduler.ts": 3370, From 49a3fd9ff3323f788743635ff195a05760c7893f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 17 Jul 2026 17:31:08 -0700 Subject: [PATCH 2/2] Address PR review feedback (#2274) - Re-export FetchProjectMarkdownFilesOptions from legacy API barrel - Pin providerId last in testNotification so config cannot override it - Safely parse non-JSON upload error bodies with HTTP status fallback - encodeURIComponent for attachment filename and document key path segments --- .../dashboard/app/api/global-and-pi-settings.ts | 3 ++- packages/dashboard/app/api/legacy.ts | 1 + packages/dashboard/app/api/task-content.ts | 15 ++++++++------- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/dashboard/app/api/global-and-pi-settings.ts b/packages/dashboard/app/api/global-and-pi-settings.ts index f9b5170651..f95436c411 100644 --- a/packages/dashboard/app/api/global-and-pi-settings.ts +++ b/packages/dashboard/app/api/global-and-pi-settings.ts @@ -57,7 +57,8 @@ export function updatePiExtensions(disabledIds: string[], projectId?: string): P export function testNotification(providerId: string, config?: Record, projectId?: string): Promise<{ success: boolean }> { return api<{ success: boolean }>(withProjectId("/settings/test-notification", projectId), { method: "POST", - body: JSON.stringify({ providerId, ...(config ?? {}) }), + // Pin providerId last so config.providerId cannot override the selected provider. + body: JSON.stringify({ ...(config ?? {}), providerId }), }); } diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index a7dbc9e39c..e63ee65849 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -265,6 +265,7 @@ export type { MarkdownFileEntry, MarkdownFileListResponse, FetchArtifactsOptions, + FetchProjectMarkdownFilesOptions, UpdateArtifactInput, } from "./task-content.js"; // Artifact types still re-exported from core for callers of legacy barrel diff --git a/packages/dashboard/app/api/task-content.ts b/packages/dashboard/app/api/task-content.ts index f2298fb233..0dd2f9ed08 100644 --- a/packages/dashboard/app/api/task-content.ts +++ b/packages/dashboard/app/api/task-content.ts @@ -26,13 +26,14 @@ export async function uploadAttachment(id: string, file: File, projectId?: strin headers: withTokenHeader(), body: formData, }); - const data = await res.json(); - if (!res.ok) throw new Error((data as { error?: string }).error || "Upload failed"); + // Non-JSON error bodies (e.g. gateway 413/502 HTML) must not throw SyntaxError over the HTTP failure. + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error((data as { error?: string }).error || `Upload failed: HTTP ${res.status}`); return data as TaskAttachment; } export async function deleteAttachment(id: string, filename: string, projectId?: string): Promise { - return api(withProjectId(`/tasks/${id}/attachments/${filename}`, projectId), { method: "DELETE" }); + return api(withProjectId(`/tasks/${id}/attachments/${encodeURIComponent(filename)}`, projectId), { method: "DELETE" }); } export function fetchAgentLogs( @@ -127,11 +128,11 @@ export function fetchTaskDocuments(taskId: string, projectId?: string): Promise< } export function fetchTaskDocument(taskId: string, key: string, projectId?: string): Promise { - return api(withProjectId(`/tasks/${taskId}/documents/${key}`, projectId)); + return api(withProjectId(`/tasks/${taskId}/documents/${encodeURIComponent(key)}`, projectId)); } export function fetchTaskDocumentRevisions(taskId: string, key: string, projectId?: string): Promise { - return api(withProjectId(`/tasks/${taskId}/documents/${key}/revisions`, projectId)); + return api(withProjectId(`/tasks/${taskId}/documents/${encodeURIComponent(key)}/revisions`, projectId)); } export interface FetchAllDocumentsOptions { @@ -258,7 +259,7 @@ export function putTaskDocument( opts?: { author?: string; metadata?: Record }, projectId?: string, ): Promise { - return api(withProjectId(`/tasks/${taskId}/documents/${key}`, projectId), { + return api(withProjectId(`/tasks/${taskId}/documents/${encodeURIComponent(key)}`, projectId), { method: "PUT", body: JSON.stringify({ content, @@ -269,7 +270,7 @@ export function putTaskDocument( } export function deleteTaskDocument(taskId: string, key: string, projectId?: string): Promise { - return api(withProjectId(`/tasks/${taskId}/documents/${key}`, projectId), { + return api(withProjectId(`/tasks/${taskId}/documents/${encodeURIComponent(key)}`, projectId), { method: "DELETE", }); }