diff --git a/.changeset/report-screenshot-artifacts.md b/.changeset/report-screenshot-artifacts.md new file mode 100644 index 0000000000..1676b233d0 --- /dev/null +++ b/.changeset/report-screenshot-artifacts.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Store in-app report screenshots as validated local artifacts. +category: feature +dev: Replaces inline screenshot egress with the /report/attachment and screenshotArtifactId contract. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 69d3597d46..35133ef475 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -2127,7 +2127,7 @@ In **Settings → General**, choose **Review draft before filing** (the default) Reports can include a short activity trace of recent built-in view names (up to 20 entries). The trace is ordinary text and receives the same mandatory server-side scrub as every other report field on every egress path, including edited drafts and duplicate endorsements. -Choose **Attach a screenshot** to request the browser's screen-capture permission and capture one PNG frame. The modal shows the image for review and lets you remove it before continuing. Screenshot pixels are binary and cannot be text-scrubbed, so Fusion never captures or files one automatically: it is included only after this explicit per-report choice, including in automatic filing mode. Fusion first validates and files the scrubbed text report, then hosts and posts the reviewed image as a follow-up only when an approved GitHub image host is available. If that follow-up fails after hosting, Fusion compensates by deleting the uploaded image; it never inserts an unhosted data URL into report text. +Choose **Store a screenshot locally** to request browser screen-capture permission. Fusion captures and uploads one PNG frame to its local artifact registry, then requires confirmation that the screenshot may be retained before a report can reference it. The report carries only `screenshotArtifactId` and a text note that the locally stored artifact exists; pixels never leave Fusion or enter report text, including automatic filing. ## Chat-requested task verification diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 44c3eaadae..42ee3061b2 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -44,7 +44,7 @@ etc. pulled in by production modules, not test files). export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, normalizeColumnId, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, resolveEphemeralTaskCreationPolicy, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, NativeStructureRef, NativeStructureOpenTarget, NativeStructurePreviewPayload, NativeStructureUnavailablePayload, NativeStructurePreviewResult, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, ReportMode, ReportActionType, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; -export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; +export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, REPORT_ATTACHMENT_SOURCE, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, sanitizeBranchSegment, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1d197e7072..92b4c8d39f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -28,7 +28,7 @@ export type { MissionLineageApprovalResult, MissionLineageSnapshot, } from "./symbol-lock-lineage-approval.js"; -export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; +export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, REPORT_ATTACHMENT_SOURCE, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, sanitizeBranchSegment, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 89f783c4fc..ebe79c30ac 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -730,6 +730,13 @@ export interface TaskDocumentWithTask extends TaskDocument { /** Supported artifact media classes for the persisted artifact registry. */ export type ArtifactType = "document" | "image" | "video" | "audio" | "other"; +/** + * FNXC:ReportPipeline 2026-07-19-10:00: + * Report screenshots are local image artifacts with this explicit provenance. + * Only the reference may reach report egress; screenshot pixels never do. + */ +export const REPORT_ATTACHMENT_SOURCE = "report-attachment"; + /** * FNXC:ArtifactRegistry 2026-06-19-22:04: * Agents need a first-class registry for multi-type artifacts that are visible across agents and tasks. Store binary media on disk and persist only metadata plus relative URIs in SQLite so query paths stay lightweight and never inline binary bytes. diff --git a/packages/dashboard/app/api/report.ts b/packages/dashboard/app/api/report.ts index a457c8a81d..fde448173e 100644 --- a/packages/dashboard/app/api/report.ts +++ b/packages/dashboard/app/api/report.ts @@ -6,9 +6,15 @@ async function post(path: string, body: unknown) { return response.json(); } -export interface ReportScreenshot { dataUrl: string; capturedAt: string; } -export interface ReportContextInput { actionType: ReportActionType; userPrompt: string; contextRefs?: { taskId?: string; agentId?: string }; activityTrace?: string[]; screenshot?: ReportScreenshot; } - +export interface ReportContextInput { actionType: ReportActionType; userPrompt: string; contextRefs?: { taskId?: string; agentId?: string }; activityTrace?: string[]; screenshotArtifactId?: string; } export function reportDraft(input: ReportContextInput) { return post("/api/report/draft", input); } -export function reportFile(input: { actionType: ReportActionType; report: unknown; endorseIssueNumber?: number; endorseDiscussionId?: string; endorseRoadmapIssueNumber?: number; activityTrace?: string[]; screenshot?: ReportScreenshot }) { return post("/api/report/file", input); } +export function reportFile(input: { actionType: ReportActionType; report: unknown; endorseIssueNumber?: number; endorseDiscussionId?: string; endorseRoadmapIssueNumber?: number; activityTrace?: string[]; screenshotArtifactId?: string }) { return post("/api/report/file", input); } export function reportHelp(question: string) { return post("/api/report/help", { question }); } + +/** Upload is intentionally multipart: screenshot bytes never join JSON report text. */ +export async function reportAttachment(screenshot: Blob): Promise<{ artifactId: string }> { + const form = new FormData(); form.append("screenshot", screenshot, "report-screenshot.png"); + const response = await fetch("/api/report/attachment", { method: "POST", body: form }); + if (!response.ok) throw new Error((await response.json().catch(() => ({ error: response.statusText }))).error ?? response.statusText); + return response.json() as Promise<{ artifactId: string }>; +} diff --git a/packages/dashboard/app/components/ReportModal.css b/packages/dashboard/app/components/ReportModal.css index 5a5675134d..f04c84bd21 100644 --- a/packages/dashboard/app/components/ReportModal.css +++ b/packages/dashboard/app/components/ReportModal.css @@ -6,7 +6,6 @@ .report-modal__warning { color: var(--color-warning); } .report-modal__screenshot-option { display: flex; gap: var(--space-2); align-items: center; } .report-modal__screenshot-preview { display: grid; gap: var(--space-2); padding: var(--space-3); border-radius: var(--radius-md); background: color-mix(in srgb, var(--bg-raised) 85%, transparent); } -.report-modal__screenshot-preview img { max-inline-size: 100%; max-block-size: var(--space-96); object-fit: contain; } .report-modal__activity-trace { padding: var(--space-3); border-radius: var(--radius-md); background: color-mix(in srgb, var(--bg-raised) 85%, transparent); } .report-modal__activity-trace ul { margin: var(--space-2) 0 0; padding-inline-start: var(--space-5); } -@media (max-width: 768px) { .report-modal { inline-size: 100%; padding: var(--space-4); } .report-modal__screenshot-preview img { max-block-size: var(--space-64); } } +@media (max-width: 768px) { .report-modal { inline-size: 100%; padding: var(--space-4); } } diff --git a/packages/dashboard/app/components/ReportModal.tsx b/packages/dashboard/app/components/ReportModal.tsx index fec9bcc317..cc5820f0d8 100644 --- a/packages/dashboard/app/components/ReportModal.tsx +++ b/packages/dashboard/app/components/ReportModal.tsx @@ -1,9 +1,9 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { ReportActionType } from "@fusion/core"; -import { reportDraft, reportFile, reportHelp } from "../api"; -import { captureScreenshot as captureScreen, getRecentActivity, recordActivity, type ReportScreenshot } from "../utils/report-capture"; +import { reportAttachment, reportDraft, reportFile, reportHelp } from "../api"; +import { captureScreenshot as captureScreen, getRecentActivity, recordActivity } from "../utils/report-capture"; import "./ReportModal.css"; const prompts: Record = { bug: "What went wrong?", feedback: "What would you like to share?", idea: "What would you like Fusion to do?", help: "What would you like help with?" }; @@ -23,23 +23,30 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType: const [busy, setBusy] = useState(false); const [error, setError] = useState(); const [screenshotEnabled, setScreenshotEnabled] = useState(false); - const [capturedScreenshot, setCapturedScreenshot] = useState(); - const captureScreenshot = async () => { + const [screenshotArtifactId, setScreenshotArtifactId] = useState(); + const [retentionConfirmed, setRetentionConfirmed] = useState(false); + const captureGeneration = useRef(0); + const captureScreenshot = async (generation: number) => { setBusy(true); setError(undefined); try { const captured = await captureScreen(); if (!captured) throw new Error("Screen capture was unavailable or denied."); - setCapturedScreenshot(captured); + const { artifactId } = await reportAttachment(captured); + if (captureGeneration.current !== generation) return; + setScreenshotArtifactId(artifactId); } catch (captureError) { + if (captureGeneration.current !== generation) return; setScreenshotEnabled(false); setError(captureError instanceof Error ? captureError.message : "We could not capture the current screen."); - } finally { setBusy(false); } + } finally { + if (captureGeneration.current === generation) setBusy(false); + } }; const submit = async () => { if (!prompt.trim()) return; - if (screenshotEnabled && !capturedScreenshot) { - setError("Capture a screenshot before continuing, or turn attachment off."); + if (screenshotEnabled && (!screenshotArtifactId || !retentionConfirmed)) { + setError("Capture and confirm local screenshot retention before continuing, or turn attachment off."); return; } setBusy(true); @@ -50,7 +57,7 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType: const help = await reportHelp(prompt); if (help.answered) { setResult({ kind: "help", answer: help.answer }); return; } } - setResult(await reportDraft({ actionType, userPrompt: prompt, contextRefs, activityTrace: getRecentActivity(), screenshot: screenshotEnabled ? capturedScreenshot : undefined })); + setResult(await reportDraft({ actionType, userPrompt: prompt, contextRefs, activityTrace: getRecentActivity(), screenshotArtifactId: screenshotEnabled && retentionConfirmed ? screenshotArtifactId : undefined })); } catch { setError("We could not prepare your report. Check your connection and try again."); } finally { setBusy(false); } @@ -61,7 +68,7 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType: setError(undefined); try { recordActivity("report"); -setResult(await reportFile({ actionType, report: result.report, endorseIssueNumber, endorseDiscussionId, endorseRoadmapIssueNumber, activityTrace: getRecentActivity(), screenshot: screenshotEnabled ? capturedScreenshot : undefined })); +setResult(await reportFile({ actionType, report: result.report, endorseIssueNumber, endorseDiscussionId, endorseRoadmapIssueNumber, activityTrace: getRecentActivity(), screenshotArtifactId: screenshotEnabled && retentionConfirmed ? screenshotArtifactId : undefined })); } catch { setError("We could not send your report. Your draft is still here; try again."); @@ -71,12 +78,12 @@ setResult(await reportFile({ actionType, report: result.report, endorseIssueNumb {error &&

{error}

} {!result && <>

{actionType[0].toUpperCase() + actionType.slice(1)}