diff --git a/.changeset/fn-8286-review-artifacts.md b/.changeset/fn-8286-review-artifacts.md new file mode 100644 index 0000000000..bdc77e15fe --- /dev/null +++ b/.changeset/fn-8286-review-artifacts.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add review artifact controls and deliverable galleries. +category: feature +dev: Adds reviewArtifacts project policy, PROMPT.md override, task eligibility gate, and review deliverable galleries. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index e08783577b..e2407cbabb 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -592,6 +592,7 @@ Default notes: | `agentProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; alwaysApproveDelete?: boolean }` | `{}` | Approval policy for `fn_agent_create`/`fn_agent_delete` (`approvalMode` default `trusted-only`, delete approvals default on via `alwaysApproveDelete: true`). | | `sandboxProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; autoApproveBackendIds?: string[] }` | `{}` | Approval policy for sandbox host-bootstrap operations (backend install/pull/probe during `SandboxBackend.prepare()`). Default posture is strict: `approvalMode` resolves to `always`; `autoApproveBackendIds` defaults to `["native"]`. | | `completionDocumentationMode` | `"off" \| "changeset" \| "changelog"` | `"off"` | Controls triage prompt injection for release-note artifacts in future task specs. `"changeset"` requires `.changeset/*.md` workflow guidance; `"changelog"` requires updating an existing changelog file (without inventing a new one); `"off"` disables this automation. | +| `reviewArtifacts` | `"off" \| "user-facing" \| "on"` | `"off"` | Controls automatic review-deliverable generation. `"user-facing"` permits only user-facing tasks (identified by their `## Frontend UX Criteria` contract, or explicitly with `**Review Artifact Task Type:** user-facing`); backend/trivial classifications remain off. `"on"` permits every task classification. A task may override the project setting with its `PROMPT.md` header `**Review Artifacts:** off|user-facing|on`; header override takes precedence over project setting, then the conservative off fallback. | | `specStalenessEnabled` | `boolean` | `false` | Enforce automatic re-planning for stale plans. | | `specStalenessMaxAgeMs` | `number` | `21600000` | Spec staleness threshold in ms (6 hours). | | `taskStuckTimeoutMs` | `number` | `undefined` | Inactivity timeout for stuck-task recovery. | diff --git a/packages/core/src/__tests__/review-artifacts.test.ts b/packages/core/src/__tests__/review-artifacts.test.ts new file mode 100644 index 0000000000..bca8c2e29d --- /dev/null +++ b/packages/core/src/__tests__/review-artifacts.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_PROJECT_SETTINGS, + isProjectSettingsKey, + isReviewArtifact, + isReviewArtifactGenerationEligible, + LIVE_DEMO_ARTIFACT_MIME_TYPE, + parseReviewArtifactsModeOverride, + resolveReviewArtifactsMode, + type Artifact, +} from "../types.js"; + +function artifact(type: Artifact["type"], mimeType?: string): Pick { + return { type, mimeType }; +} + +describe("review artifact policy", () => { + it("defaults conservatively and registers the project setting", () => { + expect(DEFAULT_PROJECT_SETTINGS.reviewArtifacts).toBe("off"); + expect(isProjectSettingsKey("reviewArtifacts")).toBe(true); + }); + + it("resolves the persisted PROMPT.md override before project policy", () => { + expect(parseReviewArtifactsModeOverride("**Review Artifacts:** user-facing")).toBe("user-facing"); + expect(parseReviewArtifactsModeOverride("**Review Artifacts:** ON")).toBe("on"); + expect(resolveReviewArtifactsMode({ reviewArtifacts: "on" }, "**Review Artifacts:** off")).toBe("off"); + expect(resolveReviewArtifactsMode({ reviewArtifacts: "user-facing" })).toBe("user-facing"); + expect(resolveReviewArtifactsMode({})).toBe("off"); + }); + + it("gates automatic generation by policy and task classification", () => { + const userFacingPrompt = "## Frontend UX Criteria\n- visible behavior"; + const backendPrompt = "**Review Artifact Task Type:** backend"; + const trivialPrompt = "**Review Artifact Task Type:** trivial"; + + expect(isReviewArtifactGenerationEligible({ reviewArtifacts: "off" }, userFacingPrompt)).toBe(false); + expect(isReviewArtifactGenerationEligible({ reviewArtifacts: "user-facing" }, userFacingPrompt)).toBe(true); + expect(isReviewArtifactGenerationEligible({ reviewArtifacts: "user-facing" }, backendPrompt)).toBe(false); + expect(isReviewArtifactGenerationEligible({ reviewArtifacts: "user-facing" }, trivialPrompt)).toBe(false); + expect(isReviewArtifactGenerationEligible({ reviewArtifacts: "on" }, trivialPrompt)).toBe(true); + expect(isReviewArtifactGenerationEligible({ reviewArtifacts: "off" }, "**Review Artifacts:** on\n" + backendPrompt)).toBe(true); + }); + + it("includes videos and explicitly marked live-demo descriptors in review surfaces", () => { + expect(isReviewArtifact(artifact("video"))).toBe(true); + expect(isReviewArtifact(artifact("document", LIVE_DEMO_ARTIFACT_MIME_TYPE))).toBe(true); + expect(isReviewArtifact(artifact("document", `${LIVE_DEMO_ARTIFACT_MIME_TYPE}; charset=utf-8`))).toBe(true); + expect(isReviewArtifact(artifact("document"))).toBe(false); + expect(isReviewArtifact(artifact("image"))).toBe(false); + expect(isReviewArtifact(artifact("audio"))).toBe(false); + expect(isReviewArtifact(artifact("other"))).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 8ee4c17275..2bfdaaeb5c 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -61,6 +61,7 @@ describe("settings key parity", () => { expect(isProjectSettingsKey("maxConcurrent")).toBe(true); expect(isProjectSettingsKey("heartbeatMultiplier")).toBe(true); expect(isProjectSettingsKey("completionDocumentationMode")).toBe(true); + expect(isProjectSettingsKey("reviewArtifacts")).toBe(true); expect(isProjectSettingsKey("remoteAccess")).toBe(false); expect(isProjectSettingsKey("researchSettings")).toBe(true); expect(isGlobalSettingsKey("researchGlobalDefaults")).toBe(true); @@ -282,6 +283,10 @@ describe("settings key parity", () => { expect(DEFAULT_PROJECT_SETTINGS.completionDocumentationMode).toBe("off"); }); + it("defaults reviewArtifacts to off", () => { + expect(DEFAULT_PROJECT_SETTINGS.reviewArtifacts).toBe("off"); + }); + it("defaults directMergeCommitStrategy to always-squash and keeps it project-scoped", () => { expect(DEFAULT_PROJECT_SETTINGS.directMergeCommitStrategy).toBe("always-squash"); expect(isProjectSettingsKey("directMergeCommitStrategy")).toBe(true); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3c32bddff3..b7dee24445 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ -export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, 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, PLANNING_DEEPEN_CHECKPOINT_ID, PLANNING_DEEPEN_CHECKPOINT_QUESTION, PLANNING_DEEPEN_PROCEED_OPTION_ID, PLANNING_DEEPEN_PROCEED_RESPONSE_KEY, 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, 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, ExecutorOverseerSignalMemory, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; +export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, 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, REVIEW_ARTIFACTS_MODES, LIVE_DEMO_ARTIFACT_MIME_TYPE, isReviewArtifact, parseReviewArtifactsModeOverride, resolveReviewArtifactsMode, classifyReviewArtifactTask, isReviewArtifactGenerationEligible, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, resolveEphemeralTaskCreationPolicy, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, PLANNING_DEEPEN_CHECKPOINT_ID, PLANNING_DEEPEN_CHECKPOINT_QUESTION, PLANNING_DEEPEN_PROCEED_OPTION_ID, PLANNING_DEEPEN_PROCEED_RESPONSE_KEY, 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, 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, ReviewArtifactsMode, ReviewArtifactTaskClassification, 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, ExecutorOverseerSignalMemory, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index d839c2295c..0c0a52b2dd 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -494,6 +494,7 @@ export const DEFAULT_PROJECT_SETTINGS = { modelPresets: [], autoSelectModelPreset: false, completionDocumentationMode: "off", + reviewArtifacts: "off", defaultPresetBySize: {}, autoResolveConflicts: true, smartConflictResolution: true, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bb5ef9f288..1da189eeff 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -127,6 +127,7 @@ import { PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, COMPLETION_DOCUMENTATION_MODES, + REVIEW_ARTIFACTS_MODES, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, @@ -137,6 +138,7 @@ import type { ExecutionMode, PlannerOversightLevel, CompletionDocumentationMode, + ReviewArtifactsMode, ThemeMode, ColorTheme, Locale, @@ -149,6 +151,7 @@ export { PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, COMPLETION_DOCUMENTATION_MODES, + REVIEW_ARTIFACTS_MODES, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, @@ -159,6 +162,7 @@ export type { ExecutionMode, PlannerOversightLevel, CompletionDocumentationMode, + ReviewArtifactsMode, ThemeMode, ColorTheme, Locale, @@ -793,6 +797,75 @@ export interface ArtifactWithTask extends Artifact { taskColumn?: string; } + +/* +FNXC:ReviewArtifacts 2026-07-17-12:00: +Remote-desktop producers can register a document descriptor through the existing +artifact registry by assigning this MIME type. The descriptor remains a document +in the gallery, avoiding a raw external-session link while still making the +review deliverable visible on both review surfaces. +*/ +export const LIVE_DEMO_ARTIFACT_MIME_TYPE = "application/vnd.runfusion.live-demo+json"; + +/* +FNXC:ReviewArtifacts 2026-07-17-12:00: +Review surfaces admit feature videos and explicitly marked live-demo descriptors. +Ordinary documents remain excluded; the marker uses the existing persisted +mimeType field because agent artifact registration already forwards it without +requiring a parallel schema or metadata-registration path. +*/ +export function isReviewArtifact(artifact: Pick): boolean { + return artifact.type === "video" + || (artifact.type === "document" && artifact.mimeType?.toLowerCase().split(";", 1)[0] === LIVE_DEMO_ARTIFACT_MIME_TYPE); +} + +/** Reads the persisted PROMPT.md override without adding task-store persistence. */ +export function parseReviewArtifactsModeOverride(prompt: string | undefined): ReviewArtifactsMode | undefined { + if (!prompt) return undefined; + const match = prompt.match(/^\*\*Review Artifacts:\*\*\s*(off|user-facing|on)\s*$/im); + return match?.[1]?.toLowerCase() as ReviewArtifactsMode | undefined; +} + +/** Resolves review-artifact generation policy: PROMPT header → project setting → conservative default. */ +export function resolveReviewArtifactsMode( + settings: Pick, + prompt?: string, +): ReviewArtifactsMode { + return parseReviewArtifactsModeOverride(prompt) ?? settings.reviewArtifacts ?? "off"; +} + +export type ReviewArtifactTaskClassification = "user-facing" | "backend" | "trivial"; + +/* +FNXC:ReviewArtifacts 2026-07-17-13:00: +The `user-facing` policy must be a real generation gate, not a label that +producers reinterpret. Triage may declare a task classification in PROMPT.md; +otherwise a task with the standard frontend UX contract is user-facing and all +other work conservatively remains backend. This keeps trivial/backend work from +silently producing review media while allowing `on` or the existing mode header +to explicitly opt in. +*/ +export function classifyReviewArtifactTask(prompt: string | undefined): ReviewArtifactTaskClassification { + const explicit = prompt?.match(/^\*\*Review Artifact Task Type:\*\*\s*(user-facing|backend|trivial)\s*$/im)?.[1]?.toLowerCase(); + if (explicit === "user-facing" || explicit === "backend" || explicit === "trivial") return explicit; + if (/^##\s+Frontend UX Criteria\s*$/im.test(prompt ?? "")) return "user-facing"; + return "backend"; +} + +/** + * Determines whether an automatic review-artifact producer may generate media + * for a task. A mode marker still wins policy resolution; task classification + * controls the `user-facing` mode only. + */ +export function isReviewArtifactGenerationEligible( + settings: Pick, + prompt?: string, + classification = classifyReviewArtifactTask(prompt), +): boolean { + const mode = resolveReviewArtifactsMode(settings, prompt); + return mode === "on" || (mode === "user-facing" && classification === "user-facing"); +} + /** * Goal-citation Slice 2 success-signal surfaces where goal IDs are extracted. */ @@ -3569,6 +3642,8 @@ export interface ProjectSettings { * - "changelog": require updating an existing changelog file (do not invent a new one) * Default: "off" */ completionDocumentationMode?: CompletionDocumentationMode; + /** Controls whether task review deliverables are generated: off, user-facing, or on. PROMPT.md may override it. */ + reviewArtifacts?: ReviewArtifactsMode; /** Mapping of task sizes to preset IDs used for auto-selection during task creation. */ defaultPresetBySize?: { S?: string; M?: string; L?: string }; /** When true, auto-merge will automatically resolve common conflict patterns diff --git a/packages/core/src/types/execution-and-ui.ts b/packages/core/src/types/execution-and-ui.ts index c7ec454ab1..fdc7f7595d 100644 --- a/packages/core/src/types/execution-and-ui.ts +++ b/packages/core/src/types/execution-and-ui.ts @@ -44,6 +44,14 @@ export const DEFAULT_PLANNER_OVERSIGHT_LEVEL: PlannerOversightLevel = "autonomou export const COMPLETION_DOCUMENTATION_MODES = ["off", "changeset", "changelog"] as const; export type CompletionDocumentationMode = (typeof COMPLETION_DOCUMENTATION_MODES)[number]; +/* +FNXC:ReviewArtifacts 2026-07-17-12:00: +Review-artifact production is opt-in by default: backend and trivial work stay off, +while user-facing tasks can opt in without making every task generate media. +*/ +export const REVIEW_ARTIFACTS_MODES = ["off", "user-facing", "on"] as const; +export type ReviewArtifactsMode = (typeof REVIEW_ARTIFACTS_MODES)[number]; + /** Theme mode for light/dark/system preference */ export const THEME_MODES = ["dark", "light", "system"] as const; export type ThemeMode = (typeof THEME_MODES)[number]; diff --git a/packages/dashboard/app/components/TaskReviewTab.tsx b/packages/dashboard/app/components/TaskReviewTab.tsx index 51f920bf42..69df4e400e 100644 --- a/packages/dashboard/app/components/TaskReviewTab.tsx +++ b/packages/dashboard/app/components/TaskReviewTab.tsx @@ -1,5 +1,5 @@ import "./TaskReviewTab.css"; -import { getErrorMessage, type PrCheckStatus, type Task, type TaskDetail, type TaskReviewSummary } from "@fusion/core"; +import { getErrorMessage, isReviewArtifact, type PrCheckStatus, type Task, type TaskDetail, type TaskReviewSummary } from "@fusion/core"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; import { Bot, ExternalLink, GitPullRequest, User } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -10,8 +10,10 @@ import type { ToastType } from "../hooks/useToast"; import { linkifyFilePaths } from "../utils/filePathLinkify"; import { resolveReviewCommentAuthor } from "../utils/githubCommentAuthor"; import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback"; +import { ArtifactsGallery } from "./ArtifactsGallery"; import { LoadingSpinner } from "./LoadingSpinner"; import { MailboxMessageContent } from "./MailboxMessageContent"; +import { useArtifacts } from "../hooks/useArtifacts"; interface Props { task: Task | TaskDetail; @@ -164,6 +166,18 @@ export function TaskReviewTab({ ); const [isSavingAutoMergePreference, setIsSavingAutoMergePreference] = useState(false); const [addressingPrFeedback, setAddressingPrFeedback] = useState(false); + const [isMobile, setIsMobile] = useState(() => typeof window !== "undefined" && window.matchMedia?.("(max-width: 768px)").matches === true); + const { artifacts } = useArtifacts({ projectId, taskId: task.id }); + const reviewArtifacts = useMemo(() => artifacts.filter(isReviewArtifact), [artifacts]); + + useEffect(() => { + const query = typeof window === "undefined" ? undefined : window.matchMedia?.("(max-width: 768px)"); + if (!query) return; + const update = () => setIsMobile(query.matches); + update(); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); const isPrMode = review?.source === "pull-request"; const prSummary = isPrMode ? review?.summary as TaskReviewSummary | undefined : undefined; @@ -461,6 +475,18 @@ export function TaskReviewTab({ FNXC:TaskReviewTab 2026-06-27-23:38: PR-linked tasks need Review-tab context that is already present in the GitHub review payload: decision, reviewers, checks, blockers, and per-item author/state/GitHub links. Keep this branch gated to pull-request mode so reviewer-agent reviews retain their established direct-mode layout. */} + {reviewArtifacts.length > 0 ? ( +
+

{t("taskReview.reviewArtifacts", "Review artifacts")}

+ {}} + /> +
+ ) : null} {isPrMode ? (
diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index dc194f50f4..58751e38b0 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -1286,6 +1286,14 @@ describe("SettingsModal", () => { scope: "project", expectedKey: "completionDocumentationMode", }, + { + section: "General · Project", + label: "Review Artifacts", + kind: "select", + value: "user-facing", + scope: "project", + expectedKey: "reviewArtifacts", + }, { section: "General · Project", label: "Auto-cleanup old chats", diff --git a/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx index 567ba01781..010f58289d 100644 --- a/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx @@ -4,7 +4,9 @@ FN-6441 rescued this orphaned component test after standalone dashboard-app exec */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { act, render as rtlRender, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { LIVE_DEMO_ARTIFACT_MIME_TYPE } from "@fusion/core"; import { TaskReviewTab } from "../TaskReviewTab"; +import { useArtifacts } from "../../hooks/useArtifacts"; import { makeTask } from "./TaskDetailModal.test-helpers"; import { loadAllAppCss } from "../../test/cssFixture"; @@ -18,12 +20,17 @@ const apiMocks = vi.hoisted(() => ({ addressPrFeedback: vi.fn(), })); +vi.mock("../../hooks/useArtifacts", () => ({ + useArtifacts: vi.fn(), +})); + vi.mock("../../api", () => ({ fetchTaskReview: apiMocks.fetchTaskReview, refreshTaskReview: apiMocks.refreshTaskReview, reviseTaskReviewItems: apiMocks.reviseTaskReviewItems, updateTask: apiMocks.updateTask, addressPrFeedback: apiMocks.addressPrFeedback, + artifactMediaUrlWithToken: vi.fn((id: string) => `/api/artifacts/${id}/media`), })); async function renderWithAct(ui: Parameters[0]) { @@ -38,6 +45,26 @@ describe("TaskReviewTab", () => { beforeEach(() => { vi.clearAllMocks(); window.localStorage.clear(); + vi.mocked(useArtifacts).mockReturnValue({ artifacts: [], loading: false, error: null, refresh: vi.fn() }); + }); + + it("renders videos and marked live-demo descriptors while hiding an empty review-artifact affordance", async () => { + apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: { source: "reviewer-agent", items: [], addressing: [] }, automationStatus: null, emptyMessage: null }); + const { rerender } = await renderWithAct(); + expect(screen.queryByTestId("task-review-artifacts")).not.toBeInTheDocument(); + + vi.mocked(useArtifacts).mockReturnValue({ + artifacts: [ + { id: "video", type: "video", title: "Feature walkthrough", authorId: "agent", authorType: "agent", taskId: "FN-1", createdAt: "2026-07-17T00:00:00.000Z", updatedAt: "2026-07-17T00:00:00.000Z" }, + { id: "document", type: "document", title: "Task notes", authorId: "agent", authorType: "agent", taskId: "FN-1", createdAt: "2026-07-17T00:00:00.000Z", updatedAt: "2026-07-17T00:00:00.000Z" }, + { id: "live-demo", type: "document", mimeType: LIVE_DEMO_ARTIFACT_MIME_TYPE, title: "Live demo descriptor", authorId: "agent", authorType: "agent", taskId: "FN-1", createdAt: "2026-07-17T00:00:00.000Z", updatedAt: "2026-07-17T00:00:00.000Z" }, + ], loading: false, error: null, refresh: vi.fn(), + }); + rerender(); + expect(await screen.findByTestId("task-review-artifacts")).toBeInTheDocument(); + expect(screen.getByText("Feature walkthrough")).toBeInTheDocument(); + expect(screen.queryByText("Task notes")).not.toBeInTheDocument(); + expect(screen.getByText("Live demo descriptor")).toBeInTheDocument(); }); it("renders direct-mode empty state when no reviewer feedback exists", async () => { diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index 23653cbf04..d33d34eda0 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -10,6 +10,7 @@ import { TokensArea } from "./areas/TokensArea"; import { ToolsArea } from "./areas/ToolsArea"; import { ActivityArea } from "./areas/ActivityArea"; import { ProductivityArea } from "./areas/ProductivityArea"; +import { ReviewArtifactsArea } from "./areas/ReviewArtifactsArea"; import { TeamArea } from "./areas/TeamArea"; import { WorkflowArea } from "./areas/WorkflowArea"; import { EcosystemArea } from "./areas/EcosystemArea"; @@ -40,6 +41,7 @@ type SubViewId = | "tools" | "activity" | "productivity" + | "review-artifacts" | "team" | "workflows" | "ecosystem" @@ -84,6 +86,7 @@ function useSubViews(nodesEnabled: boolean): SubView[] { { id: "tools", label: t("commandCenter.tabs.tools", "Tools") }, { id: "activity", label: t("commandCenter.tabs.activity", "Activity") }, { id: "productivity", label: t("commandCenter.tabs.productivity", "Productivity") }, + { id: "review-artifacts", label: t("commandCenter.tabs.reviewArtifacts", "Review artifacts") }, { id: "team", label: t("commandCenter.tabs.team", "Team") }, { id: "workflows", label: t("commandCenter.tabs.workflows", "Workflows") }, { id: "ecosystem", label: t("commandCenter.tabs.ecosystem", "Ecosystem") }, @@ -619,6 +622,8 @@ export function CommandCenter({ return ; case "productivity": return ; + case "review-artifacts": + return ; case "team": return ; case "workflows": diff --git a/packages/dashboard/app/components/command-center/areas/ReviewArtifactsArea.tsx b/packages/dashboard/app/components/command-center/areas/ReviewArtifactsArea.tsx new file mode 100644 index 0000000000..947cb94da6 --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/ReviewArtifactsArea.tsx @@ -0,0 +1,59 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { isReviewArtifact } from "@fusion/core"; +import { ArtifactsGallery } from "../../ArtifactsGallery"; +import { useArtifacts } from "../../../hooks/useArtifacts"; +import type { ToastType } from "../../../hooks/useToast"; +import { AreaShell } from "./AreaShell"; + +/* +FNXC:ReviewArtifacts 2026-07-17-12:00: +The Command Center Review artifacts panel is the cross-task deliverable surface. +It reuses the registry gallery for videos and MIME-marked live-demo descriptors; +ordinary documents remain hidden, and the descriptor stays a gallery document +rather than becoming a raw external-session link before FN-8290's renderer. +*/ +export function ReviewArtifactsArea({ projectId, addToast = () => {} }: { projectId?: string; addToast?: (message: string, type?: ToastType) => void }) { + const { t } = useTranslation("app"); + const { artifacts, loading, error } = useArtifacts({ projectId }); + const [isMobile, setIsMobile] = useState(() => typeof window !== "undefined" && window.matchMedia?.("(max-width: 768px)").matches === true); + const reviewArtifacts = useMemo(() => artifacts.filter((artifact) => Boolean(artifact.taskId) && isReviewArtifact(artifact)), [artifacts]); + /* + FNXC:ReviewArtifacts 2026-07-18-19:25: + The cross-task Command Center cannot open a task through this area, so omit + ArtifactsGallery's task-link affordance rather than render an interactive + control with a no-op callback. The task ID is retained above solely to limit + this panel to deliverables registered for a task. + */ + const galleryArtifacts = useMemo(() => reviewArtifacts.map(({ taskId: _taskId, taskTitle: _taskTitle, ...artifact }) => artifact), [reviewArtifacts]); + + useEffect(() => { + const query = typeof window === "undefined" ? undefined : window.matchMedia?.("(max-width: 768px)"); + if (!query) return; + const update = () => setIsMobile(query.matches); + update(); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + + return ( + +
+

{t("commandCenter.reviewArtifacts.title", "Review artifacts")}

+ undefined} + /> +
+
+ ); +} diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/ReviewArtifactsArea.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/ReviewArtifactsArea.test.tsx new file mode 100644 index 0000000000..c646dbd4d2 --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/__tests__/ReviewArtifactsArea.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { LIVE_DEMO_ARTIFACT_MIME_TYPE } from "@fusion/core"; +import { ReviewArtifactsArea } from "../ReviewArtifactsArea"; +import { useArtifacts } from "../../../../hooks/useArtifacts"; + +vi.mock("../../../../hooks/useArtifacts", () => ({ useArtifacts: vi.fn() })); +vi.mock("../../../ArtifactsGallery", () => ({ + ArtifactsGallery: ({ artifacts }: { artifacts: Array<{ title: string; taskId?: string }> }) => ( +
artifact.taskId).length}> + {artifacts.map((artifact) => artifact.title).join(",")} +
+ ), +})); + +const mockUseArtifacts = vi.mocked(useArtifacts); +const base = { authorId: "agent", authorType: "agent" as const, taskId: "FN-1", createdAt: "2026-07-17T00:00:00.000Z", updatedAt: "2026-07-17T00:00:00.000Z" }; + +describe("ReviewArtifactsArea", () => { + it("degrades to an empty state when no video review deliverables exist", () => { + mockUseArtifacts.mockReturnValue({ artifacts: [{ ...base, id: "doc", type: "document", title: "Descriptor" }], loading: false, error: null, refresh: vi.fn() }); + render(); + expect(screen.getByTestId("cc-area-review-artifacts-empty")).toBeInTheDocument(); + expect(screen.queryByTestId("review-gallery")).not.toBeInTheDocument(); + }); + + it("surfaces task-scoped videos and marked live-demo descriptors while filtering ordinary documents", () => { + mockUseArtifacts.mockReturnValue({ artifacts: [ + { ...base, id: "video", type: "video", title: "Feature video" }, + { ...base, id: "notes", type: "document", title: "Task notes" }, + { ...base, id: "live-demo", type: "document", mimeType: LIVE_DEMO_ARTIFACT_MIME_TYPE, title: "Live-demo descriptor" }, + ], loading: false, error: null, refresh: vi.fn() }); + render(); + expect(screen.getByTestId("review-gallery")).toHaveTextContent("Feature video"); + expect(screen.getByTestId("review-gallery")).toHaveTextContent("Live-demo descriptor"); + expect(screen.getByTestId("review-gallery")).not.toHaveTextContent("Task notes"); + expect(screen.getByTestId("review-gallery")).toHaveAttribute("data-task-link-count", "0"); + }); +}); diff --git a/packages/dashboard/app/components/settings/section-keys.ts b/packages/dashboard/app/components/settings/section-keys.ts index 42087448ae..27f7ad8a89 100644 --- a/packages/dashboard/app/components/settings/section-keys.ts +++ b/packages/dashboard/app/components/settings/section-keys.ts @@ -64,6 +64,7 @@ const PROJECT_SECTION_KEYS: Record = { "chatRoomRecentVerbatimMessages", "chatRoomSummaryMaxChars", "completionDocumentationMode", + "reviewArtifacts", "enabledBuiltinWorkflowIds", "ephemeralAgentTaskCreationPolicy", "ephemeralAgentsEnabled", diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index 1058c78b1f..62209dfa3b 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -273,6 +273,26 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
+
+ {/* + FNXC:ReviewArtifacts 2026-07-17-12:00: + Operators choose whether future tasks may generate review deliverables. + Per-task PROMPT.md markers remain the final override, so conservative + project policy does not require new task-store persistence. + */} +
+ + {t("settings.general.reviewArtifactsHint", " Controls whether eligible future tasks generate review deliverables. User-facing limits generation to user-facing work; on enables it for all eligible tasks. Individual PROMPT.md headers can override this. Default: off.")} +
+ +
{/* FNXC:ReportPipeline 2026-07-16-19:15: diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index 5b27b45dab..c475860dfd 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -254,6 +254,7 @@ const SETTING_DESCRIPTION_KEYS: Record = { chatRoomRecentVerbatimMessages: "general.numberOfMostRecentChatRoomMessagesKept", chatRoomSummaryMaxChars: "general.hardCapOnTheSynthesizedEarlierRoomContext", completionDocumentationMode: "general.workflowsOrChangelogModeWhenContributorsShouldUpdate", + reviewArtifacts: "general.reviewArtifactsHint", ephemeralAgentTaskCreationPolicy: "general.ephemeralAgentTaskCreationPolicyHint", ephemeralAgentsEnabled: "general.whenEnabledDefaultFusionSpawnsShortLived", githubLinkImportedIssuesToTracking: "general.whenEnabledImportedGitHubIssuesUseTheirSource", diff --git a/packages/engine/src/__tests__/agent-artifact-tools.test.ts b/packages/engine/src/__tests__/agent-artifact-tools.test.ts index 575563d0b2..df50cff180 100644 --- a/packages/engine/src/__tests__/agent-artifact-tools.test.ts +++ b/packages/engine/src/__tests__/agent-artifact-tools.test.ts @@ -20,7 +20,7 @@ const TASK_ID = "FN-6778"; const AUTHOR_ID = "agent-007"; const PNG_IMAGE_BYTES = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", "base64"); -type ArtifactStore = Pick; +type ArtifactStore = Pick; type ArtifactMessageStore = Pick; @@ -159,6 +159,23 @@ describe("artifact register tool", () => { expect(getText(result)).not.toContain("ERROR:"); }); + it("gates task review-artifact producers by user-facing task eligibility", async () => { + const registerArtifact = vi.fn().mockResolvedValue(createMockArtifact({ type: "video" })); + const getTask = vi.fn(); + const getSettings = vi.fn().mockResolvedValue({ reviewArtifacts: "user-facing" }); + const store = { registerArtifact, getTask, getSettings } as unknown as TaskStore; + const tool = createArtifactRegisterTool(store, AUTHOR_ID); + + getTask.mockResolvedValue({ prompt: "## Frontend UX Criteria\n- visible behavior" } as Awaited>); + await runTool(tool, "call-user-facing-video", { type: "video", title: "Walkthrough", taskId: TASK_ID }); + expect(registerArtifact).toHaveBeenCalledTimes(1); + + getTask.mockResolvedValue({ prompt: "**Review Artifact Task Type:** backend" } as Awaited>); + const blocked = await runTool(tool, "call-backend-video", { type: "video", title: "Backend walkthrough", taskId: TASK_ID }); + expect(registerArtifact).toHaveBeenCalledTimes(1); + expect(getText(blocked)).toContain("Review artifact generation is disabled"); + }); + it("rejects empty, non-image, and arbitrary-byte base64 payloads without registering", async () => { const { store, registerArtifact } = createMockStore(); const tool = createArtifactRegisterTool(store, AUTHOR_ID); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 1d7352e58b..0f4037bc72 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -1816,6 +1816,11 @@ async function registerArtifactForAgent( } const filePayload = await readArtifactFileFromPath(params, options?.baseDir); const data = filePayload ? filePayload.data : decodeArtifactDataBase64(params); + await assertReviewArtifactGenerationEligible(store, { + type: params.type, + mimeType: filePayload?.mimeType ?? params.mimeType, + taskId: params.taskId ?? options?.defaultTaskId, + }); const input: ArtifactCreateInput = { type: params.type, title: params.title, @@ -1849,6 +1854,27 @@ async function registerArtifactForAgent( }; } } + +/** + * FNXC:ReviewArtifacts 2026-07-17-13:00: + * Automatic artifact producers share the core eligibility resolver at the + * registration seam so `user-facing` excludes backend/trivial tasks instead of + * relying on each future video/live-demo producer to recreate policy. Untargeted + * artifacts remain registry-wide and are not a task review deliverable. + */ +async function assertReviewArtifactGenerationEligible( + store: TaskStore, + artifact: Pick, +): Promise { + if (!artifact.taskId || !fusionCore.isReviewArtifact(artifact)) return; + if (typeof store.getTask !== "function" || typeof store.getSettings !== "function") return; + + const [task, settings] = await Promise.all([store.getTask(artifact.taskId), store.getSettings()]); + if (!fusionCore.isReviewArtifactGenerationEligible(settings, task.prompt)) { + throw new Error(`Review artifact generation is disabled for task ${artifact.taskId} by its reviewArtifacts policy.`); + } +} + /** * FNXC:ArtifactRegistry 2026-06-29-00:00: * Agents need a portable way to create task-scoped image artifacts without reading arbitrary local files. `dataBase64` decodes inside the tool and then uses TaskStore's existing binary persistence path so registry rows continue to store only managed artifact URIs. diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index f57670ac66..e84b19f176 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -1765,6 +1765,10 @@ "volumeHint": "volume, not outcome", "volumeTitle": "Volume (proxy)" }, + "reviewArtifacts": { + "empty": "No review artifacts are available yet.", + "title": "Review artifacts" + }, "range": { "custom": "Custom range", "dialogLabel": "Select date range", @@ -1811,6 +1815,7 @@ "nodes": "Nodes", "overview": "Overview", "productivity": "Productivity", + "reviewArtifacts": "Review artifacts", "reliability": "Reliability", "signals": "Signals", "system": "System", @@ -5879,6 +5884,8 @@ "chatHistory": "Chat history", "chatRooms": "Chat Rooms", "completionDocumentationAutomation": "Completion Documentation Automation", + "reviewArtifacts": "Review Artifacts", + "reviewArtifactsHint": "Controls whether eligible future tasks generate review deliverables. User-facing limits generation to user-facing work; on enables it for all eligible tasks. Individual PROMPT.md headers can override this. Default: off.", "controlsHowFutureTaskSpecsHandleReleaseNote": " Controls how future task specs handle release-note artifacts at completion. Use changeset mode for repositories that follow ", "controlsWhetherNewlyCreatedTasksHaveGitHubIssue": " Controls whether newly created tasks have GitHub issue tracking enabled by default. Individual tasks can still override this from the task detail modal. ", "defaultRepoUsedWhenCreatingGitHubIssuesFor": "Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index 24b8774050..16ac4e3954 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -1719,6 +1719,10 @@ export default interface Resources { "volumeHint": "volume, not outcome", "volumeTitle": "Volume (proxy)" }, + "reviewArtifacts": { + "empty": "No review artifacts are available yet.", + "title": "Review artifacts" + }, "range": { "custom": "Custom range", "dialogLabel": "Select date range", @@ -1765,6 +1769,7 @@ export default interface Resources { "nodes": "Nodes", "overview": "Overview", "productivity": "Productivity", + "reviewArtifacts": "Review artifacts", "reliability": "Reliability", "signals": "Signals", "system": "System", @@ -5840,6 +5845,8 @@ export default interface Resources { "chatHistory": "Chat history", "chatRooms": "Chat Rooms", "completionDocumentationAutomation": "Completion Documentation Automation", + "reviewArtifacts": "Review Artifacts", + "reviewArtifactsHint": "Controls whether eligible future tasks generate review deliverables. User-facing limits generation to user-facing work; on enables it for all eligible tasks. Individual PROMPT.md headers can override this. Default: off.", "controlsHowFutureTaskSpecsHandleReleaseNote": " Controls how future task specs handle release-note artifacts at completion. Use changeset mode for repositories that follow ", "controlsWhetherNewlyCreatedTasksHaveGitHubIssue": " Controls whether newly created tasks have GitHub issue tracking enabled by default. Individual tasks can still override this from the task detail modal. ", "defaultRepoUsedWhenCreatingGitHubIssuesFor": "Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.",