diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c810657fc..bbf0f568c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -6980,131 +6980,41 @@ export interface MigrationResult { } // ── Messaging Types ────────────────────────────────────────────────────────── +// FNXC:CodeOrganization 2026-07-18-00:35: Keep stable re-exports after main +// landed task-proposal metadata + ephemeral policy on the mailbox contract. -/** 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 type EphemeralTaskCreationPolicy = "allow" | "upon_validation" | "deny"; - -/** Resolve the non-default policy without masking legacy persisted settings. */ -export function resolveEphemeralTaskCreationPolicy(settings: Pick): EphemeralTaskCreationPolicy { - if (settings.ephemeralAgentTaskCreationPolicy === "allow" || settings.ephemeralAgentTaskCreationPolicy === "upon_validation" || settings.ephemeralAgentTaskCreationPolicy === "deny") return settings.ephemeralAgentTaskCreationPolicy; - return settings.ephemeralAgentsCanCreateTasks === false ? "deny" : "allow"; -} - -export interface ProposedTaskMetadata { - title: string; - description: string; - priority?: TaskPriority; - workflowId?: string; - dependencies?: string[]; -} - -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; - /** Structured operator-approved follow-up task proposal. */ - kind?: string; - proposedTask?: ProposedTaskMetadata; - proposalStatus?: "pending" | "creating" | "created" | "dismissed"; - createdTaskId?: string; - /** Stable proposal key issued at send time and never rotated across reclaims. */ - proposalIdempotencyKey?: string; - /** Transient owner token for the current creating lease only. */ - claimOwnerToken?: string; - /** Durable ISO timestamp used to reclaim a creator that died before task persistence. */ - claimStartedAt?: string; -} - -/** 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, + resolveEphemeralTaskCreationPolicy, +} from "./types/messages.js"; +export { + DASHBOARD_USER_ID, + normalizeMessageParticipant, + resolveEphemeralTaskCreationPolicy, +}; +import type { + ParticipantType, + MessageType, + MessageReplyReference, + EphemeralTaskCreationPolicy, + ProposedTaskMetadata, + MessageMetadata, + Message, + MessageCreateInput, + MessageFilter, +} from "./types/messages.js"; +export type { + ParticipantType, + MessageType, + MessageReplyReference, + EphemeralTaskCreationPolicy, + ProposedTaskMetadata, + 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 000000000..118ab012f --- /dev/null +++ b/packages/core/src/types/messages.ts @@ -0,0 +1,143 @@ +/** + * FNXC:CodeOrganization 2026-07-17-12:00: + * Messaging domain types peeled from types.ts. + * + * FNXC:CodeOrganization 2026-07-18-00:35: + * Main landed task-proposal metadata and ephemeral task-creation policy on the + * mailbox contract. Keep those symbols in this peel so types.ts only re-exports. + */ + +import type { TaskPriority } from "./board.js"; + +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 type EphemeralTaskCreationPolicy = "allow" | "upon_validation" | "deny"; + +/** Resolve the non-default policy without masking legacy persisted settings. */ +export function resolveEphemeralTaskCreationPolicy(settings: { + ephemeralAgentTaskCreationPolicy?: EphemeralTaskCreationPolicy; + ephemeralAgentsCanCreateTasks?: boolean; +}): EphemeralTaskCreationPolicy { + if ( + settings.ephemeralAgentTaskCreationPolicy === "allow" || + settings.ephemeralAgentTaskCreationPolicy === "upon_validation" || + settings.ephemeralAgentTaskCreationPolicy === "deny" + ) { + return settings.ephemeralAgentTaskCreationPolicy; + } + return settings.ephemeralAgentsCanCreateTasks === false ? "deny" : "allow"; +} + +export interface ProposedTaskMetadata { + title: string; + description: string; + priority?: TaskPriority; + workflowId?: string; + dependencies?: string[]; +} + +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; + /** Structured operator-approved follow-up task proposal. */ + kind?: string; + proposedTask?: ProposedTaskMetadata; + proposalStatus?: "pending" | "creating" | "created" | "dismissed"; + createdTaskId?: string; + /** Stable proposal key issued at send time and never rotated across reclaims. */ + proposalIdempotencyKey?: string; + /** Transient owner token for the current creating lease only. */ + claimOwnerToken?: string; + /** Durable ISO timestamp used to reclaim a creator that died before task persistence. */ + claimStartedAt?: string; +} + +/** 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 000000000..f95436c41 --- /dev/null +++ b/packages/dashboard/app/api/global-and-pi-settings.ts @@ -0,0 +1,117 @@ +/** + * 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", + // Pin providerId last so config.providerId cannot override the selected provider. + body: JSON.stringify({ ...(config ?? {}), providerId }), + }); +} + +/** + * 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 fe363df2b..6e4d59f79 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,64 @@ 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, + FetchProjectMarkdownFilesOptions, + 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. @@ -321,368 +370,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 000000000..0dd2f9ed0 --- /dev/null +++ b/packages/dashboard/app/api/task-content.ts @@ -0,0 +1,277 @@ +/** + * 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, + }); + // 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/${encodeURIComponent(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/${encodeURIComponent(key)}`, projectId)); +} + +export function fetchTaskDocumentRevisions(taskId: string, key: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${taskId}/documents/${encodeURIComponent(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/${encodeURIComponent(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/${encodeURIComponent(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 2c1637006..9b04b9c53 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 89b529a58..66d0dc4c1 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 0c0e1e15a..56dd73ce0 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -1,103 +1,107 @@ { - "packages/cli/src/__tests__/extension.test.ts": 4558, - "packages/cli/src/bin.ts": 2307, - "packages/cli/src/commands/__tests__/dashboard.test.ts": 3709, - "packages/cli/src/commands/__tests__/serve.test.ts": 2250, - "packages/cli/src/commands/__tests__/task.test.ts": 3585, + "packages/cli/src/__tests__/extension.test.ts": 4451, + "packages/cli/src/bin.ts": 2331, + "packages/cli/src/commands/__tests__/serve.test.ts": 2313, + "packages/cli/src/commands/__tests__/task.test.ts": 3638, "packages/cli/src/commands/dashboard-tui/app.tsx": 4674, - "packages/cli/src/commands/dashboard.ts": 3645, - "packages/cli/src/commands/task.ts": 2373, - "packages/cli/src/extension.ts": 5575, - "packages/core/src/agent-store.ts": 3475, + "packages/cli/src/commands/dashboard.ts": 3699, + "packages/cli/src/commands/task.ts": 2366, + "packages/cli/src/extension.ts": 5687, + "packages/core/src/agent-store.ts": 3484, "packages/core/src/async-mission-store-queries.ts": 2067, - "packages/core/src/central-core.ts": 4474, - "packages/core/src/index.gate.ts": 2157, - "packages/core/src/index.ts": 2388, - "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/dashboard/app/components/AgentDetailView.tsx": 5552, + "packages/core/src/central-core.ts": 4486, + "packages/core/src/index.gate.ts": 2217, + "packages/core/src/index.ts": 2501, + "packages/core/src/mission-store.ts": 4369, + "packages/core/src/postgres/schema/project.ts": 2150, + "packages/core/src/postgres/sqlite-migrator.ts": 2112, + "packages/core/src/store.ts": 2746, + "packages/core/src/types.ts": 7101, + "packages/dashboard/app/App.tsx": 2059, + "packages/dashboard/app/api/legacy.ts": 10273, + "packages/dashboard/app/components/AgentDetailView.tsx": 5567, "packages/dashboard/app/components/AgentsView.tsx": 2254, - "packages/dashboard/app/components/ChatView.tsx": 3850, + "packages/dashboard/app/components/ChatView.tsx": 4020, + "packages/dashboard/app/components/GitHubImportModal.tsx": 2481, "packages/dashboard/app/components/GitManagerModal.tsx": 3560, - "packages/dashboard/app/components/ListView.tsx": 3087, - "packages/dashboard/app/components/MissionManager.tsx": 5010, - "packages/dashboard/app/components/ModelOnboardingModal.tsx": 3590, - "packages/dashboard/app/components/PlanningModeModal.tsx": 3783, - "packages/dashboard/app/components/QuickEntryBox.tsx": 2518, - "packages/dashboard/app/components/SettingsModal.tsx": 4572, - "packages/dashboard/app/components/TaskCard.tsx": 3856, - "packages/dashboard/app/components/TaskDetailModal.tsx": 6114, + "packages/dashboard/app/components/ListView.tsx": 3118, + "packages/dashboard/app/components/MissionManager.tsx": 5140, + "packages/dashboard/app/components/ModelOnboardingModal.tsx": 3594, + "packages/dashboard/app/components/PlanningModeModal.tsx": 4012, + "packages/dashboard/app/components/QuickEntryBox.tsx": 2562, + "packages/dashboard/app/components/SettingsModal.tsx": 5002, + "packages/dashboard/app/components/TaskCard.tsx": 3818, + "packages/dashboard/app/components/TaskDetailModal.tsx": 6506, "packages/dashboard/app/components/TerminalModal.tsx": 3322, - "packages/dashboard/app/components/WorkflowNodeEditor.tsx": 5683, - "packages/dashboard/app/components/__tests__/AgentsView.test.tsx": 2845, - "packages/dashboard/app/components/__tests__/App.test.tsx": 4567, + "packages/dashboard/app/components/WorkflowNodeEditor.tsx": 5685, + "packages/dashboard/app/components/__tests__/AgentsView.test.tsx": 2854, + "packages/dashboard/app/components/__tests__/App.test.tsx": 4640, "packages/dashboard/app/components/__tests__/Board.test.tsx": 2107, - "packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx": 2395, - "packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx": 2622, - "packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx": 3788, - "packages/dashboard/app/components/__tests__/ListView.test.tsx": 5198, - "packages/dashboard/app/components/__tests__/MailboxView.test.tsx": 2377, - "packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx": 5140, - "packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx": 2079, - "packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx": 4040, - "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 5337, - "packages/dashboard/app/components/__tests__/TaskCard.test.tsx": 7115, - "packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx": 2891, - "packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx": 3340, - "packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx": 2812, + "packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx": 2436, + "packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx": 3171, + "packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx": 3790, + "packages/dashboard/app/components/__tests__/ListView.test.tsx": 5380, + "packages/dashboard/app/components/__tests__/MailboxView.test.tsx": 2600, + "packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx": 5146, + "packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx": 2081, + "packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx": 4537, + "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 5433, + "packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx": 2021, + "packages/dashboard/app/components/__tests__/TaskCard.test.tsx": 7548, + "packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx": 3141, + "packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx": 3438, + "packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx": 2801, + "packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx": 2042, "packages/dashboard/app/components/__tests__/TerminalModal.test.tsx": 9176, "packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx": 2931, "packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx": 4353, "packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx": 2062, "packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts": 2458, - "packages/dashboard/app/hooks/__tests__/useChat.test.ts": 5355, - "packages/dashboard/app/hooks/__tests__/useTasks.test.ts": 3712, - "packages/dashboard/src/__tests__/chat-manager.test.ts": 3695, + "packages/dashboard/app/hooks/__tests__/useChat.test.ts": 5376, + "packages/dashboard/app/hooks/__tests__/useTasks.test.ts": 3766, + "packages/dashboard/src/__tests__/chat-manager.test.ts": 3796, "packages/dashboard/src/__tests__/file-service.test.ts": 2310, - "packages/dashboard/src/__tests__/github.test.ts": 2319, + "packages/dashboard/src/__tests__/github.test.ts": 2453, "packages/dashboard/src/__tests__/routes-auth.test.ts": 5510, - "packages/dashboard/src/__tests__/routes-automation.test.ts": 3068, - "packages/dashboard/src/__tests__/routes-github.test.ts": 3169, - "packages/dashboard/src/__tests__/routes-planning.test.ts": 5119, + "packages/dashboard/src/__tests__/routes-automation.test.ts": 3089, + "packages/dashboard/src/__tests__/routes-github.test.ts": 3792, + "packages/dashboard/src/__tests__/routes-planning.test.ts": 5265, "packages/dashboard/src/__tests__/routes-tasks.test.ts": 2949, "packages/dashboard/src/__tests__/usage.test.ts": 5261, - "packages/dashboard/src/chat.ts": 2861, - "packages/dashboard/src/github.ts": 4587, - "packages/dashboard/src/mission-routes.ts": 3895, - "packages/dashboard/src/planning.ts": 3364, - "packages/dashboard/src/routes.ts": 5600, - "packages/dashboard/src/routes/register-git-github.ts": 5984, - "packages/dashboard/src/routes/register-settings-memory-routes.ts": 2382, - "packages/dashboard/src/routes/register-task-workflow-routes.ts": 5366, - "packages/dashboard/src/server.ts": 2860, + "packages/dashboard/src/chat.ts": 3069, + "packages/dashboard/src/github.ts": 4877, + "packages/dashboard/src/mission-routes.ts": 3909, + "packages/dashboard/src/planning.ts": 3625, + "packages/dashboard/src/routes.ts": 5629, + "packages/dashboard/src/routes/register-git-github.ts": 6342, + "packages/dashboard/src/routes/register-settings-memory-routes.ts": 2403, + "packages/dashboard/src/routes/register-task-workflow-routes.ts": 5490, + "packages/dashboard/src/server.ts": 2862, "packages/dashboard/src/usage.ts": 2398, - "packages/engine/src/__tests__/cron-runner.test.ts": 2326, - "packages/engine/src/__tests__/executor-prompt.test.ts": 2685, + "packages/engine/src/__tests__/cron-runner.test.ts": 2334, + "packages/engine/src/__tests__/executor-prompt.test.ts": 2716, "packages/engine/src/__tests__/executor-worktree.test.ts": 2723, - "packages/engine/src/__tests__/heartbeat-executor.test.ts": 4295, - "packages/engine/src/__tests__/heartbeat-scheduler.test.ts": 3018, - "packages/engine/src/__tests__/merger-merge-lifecycle.test.ts": 3693, - "packages/engine/src/__tests__/merger-verification.test.ts": 3163, - "packages/engine/src/__tests__/mission-execution-loop.test.ts": 2904, - "packages/engine/src/__tests__/pi-create-fn-agent.test.ts": 3067, - "packages/engine/src/__tests__/project-engine.test.ts": 3482, - "packages/engine/src/__tests__/restart.integration.test.ts": 2013, - "packages/engine/src/__tests__/self-healing.test.ts": 11148, - "packages/engine/src/__tests__/step-session-executor.test.ts": 3217, - "packages/engine/src/__tests__/triage.test.ts": 6605, - "packages/engine/src/agent-heartbeat.ts": 5109, - "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/pi.ts": 2799, - "packages/engine/src/project-engine.ts": 5051, - "packages/engine/src/scheduler.ts": 3370, - "packages/engine/src/self-healing.ts": 12342, - "packages/engine/src/triage.ts": 3133, + "packages/engine/src/__tests__/heartbeat-executor.test.ts": 4455, + "packages/engine/src/__tests__/heartbeat-scheduler.test.ts": 3327, + "packages/engine/src/__tests__/merger-merge-lifecycle.test.ts": 3697, + "packages/engine/src/__tests__/merger-verification.test.ts": 3177, + "packages/engine/src/__tests__/mission-execution-loop.test.ts": 2984, + "packages/engine/src/__tests__/pi-create-fn-agent.test.ts": 3148, + "packages/engine/src/__tests__/project-engine.test.ts": 3691, + "packages/engine/src/__tests__/restart.integration.test.ts": 2022, + "packages/engine/src/__tests__/self-healing.test.ts": 11692, + "packages/engine/src/__tests__/step-session-executor.test.ts": 3226, + "packages/engine/src/__tests__/triage.test.ts": 7392, + "packages/engine/src/agent-heartbeat.ts": 5370, + "packages/engine/src/agent-tools.ts": 5230, + "packages/engine/src/executor.ts": 20110, + "packages/engine/src/merger-ai.ts": 2401, + "packages/engine/src/merger.ts": 11208, + "packages/engine/src/pi.ts": 2814, + "packages/engine/src/project-engine.ts": 5410, + "packages/engine/src/scheduler.ts": 3399, + "packages/engine/src/self-healing.ts": 12399, + "packages/engine/src/triage.ts": 3602, "packages/pi-claude-cli/src/__tests__/provider.test.ts": 2028, "plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx": 2583, "scripts/__tests__/test-changed.test.mjs": 2116