Files
fusion/packages/dashboard/app/components/settings/section-keys.ts
gsxdsm ea53cbd4ff FN-9021: send mailbox notices for task recommendations
Completed tasks now optionally send captured recommendations to the operator mailbox.

- Add idempotent, non-blocking mailbox notices after accepted task completion.
- Expose a project setting and localized dashboard control for recommendation notices.
- Cover notification dispatch, settings parity, and completion-path behavior with tests.

Files changed:
 .../fn-9021-recommendation-mailbox-notice.md       |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 docs/settings-reference.md                         |   1 +
 .../core/src/__tests__/settings-parity.test.ts     |   2 +
 .../__tests__/task-recommendation-notice.test.ts   |  77 +++++++++++++
 packages/core/src/config/settings-schema.ts        |   2 +
 packages/core/src/index.ts                         |   9 ++
 packages/core/src/task-recommendation-notice.ts    | 122 +++++++++++++++++++++
 packages/core/src/types/settings/settings-scope.ts |   6 +
 .../__tests__/SettingsModal.general.test.tsx       |  10 ++
 .../app/components/settings/section-keys.ts        |   1 +
 .../settings/sections/GeneralSection.tsx           |  15 +++
 .../settings-default-descriptions.test.tsx         |   1 +
 .../executor-task-recommendations.test.ts          |  39 ++++++-
 .../executor/completion-recommendation-notice.ts   |  52 +++++++++
 .../engine/src/executor/create-task-done-tool.ts   |  12 ++
 packages/engine/src/executor/plan-review-no-op.ts  |  12 ++
 packages/engine/src/runtimes/in-process-runtime.ts |  14 +++
 packages/i18n/locales/en/app.json                  |   4 +-
 packages/i18n/locales/es/app.json                  |   4 +-
 packages/i18n/locales/fr/app.json                  |   4 +-
 packages/i18n/locales/ko/app.json                  |   4 +-
 packages/i18n/locales/pt-BR/app.json               |   4 +-
 packages/i18n/locales/zh-CN/app.json               |   4 +-
 packages/i18n/locales/zh-TW/app.json               |   4 +-
 packages/i18n/src/resources.d.ts                   |  40 ++++++-
 26 files changed, 442 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-9021
Fusion-Task-Lineage: f6c5c93c-a3b6-470b-9efb-79e6b4003985
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-12 21:15:53 -07:00

292 lines
12 KiB
TypeScript

/**
* Section -> owned settings keys (+ scope) registry (FN-7506).
*
* FNXC:SettingsReset 2026-07-04-00:00:
* "Reset this menu" in the Settings footer must touch ONLY the active
* section's own keys, at the correct scope (global vs project), and must
* never silently reset a section that isn't a simple settings blob. This
* module is the single source of truth for that mapping so the reset flow
* and `splitSettingsSave` (save-split.ts) never diverge on which keys belong
* to which section. Global-section entries are re-exported from
* `GLOBAL_SECTION_KEYS` in save-split.ts (do not duplicate that list here);
* this file adds the missing PROJECT-section entries and the shared
* exclusion list.
*
* Design decisions (recorded in the `plan` task document for FN-7506):
* 1. A key-owning section maps to { scope, keys }. Every key here MUST be a
* real member of GLOBAL_SETTINGS_KEYS or PROJECT_SETTINGS_KEYS matching
* the declared scope (enforced by section-keys.test.ts).
* 2. Non-key sections (secrets, global-mcp, mcp, plugins, memory,
* authentication, prompts, cli-agents, and the three runtime sections)
* are NOT a simple settings blob — they are managed by their own CRUD
* flows/routes. They are explicitly EXCLUDED from per-menu reset rather
* than silently reset. See EXCLUDED_RESET_SECTIONS below.
* 3. Reset semantics: GLOBAL keys reset to the canonical
* `DEFAULT_GLOBAL_SETTINGS` value (write). PROJECT keys reset via
* null-as-delete (write `null`) so an inherited/overridable project
* setting reverts to its inherited/default value, matching the existing
* null-as-delete convention already used by `splitSettingsSave`.
* 4. Each key is assigned to exactly ONE canonical owning section per scope,
* so every section's reset is scoped to a disjoint key set. A key may
* still appear once at each scope when it is genuinely dual-scope (e.g.
* `githubTrackingDefaultRepo`); the disjointness guard is per-scope.
*
* FNXC:SourceControl 2026-07-15-20:30:
* Rule 4 used to arbitrate a real UI duplicate rather than a naming overlap:
* `gitlabEnabled` was rendered and written from BOTH "general" and "merge", so
* the registry awarded it to "general" to keep reset sets disjoint while the
* duplicate toggle stayed on screen. Both sections' GitHub/GitLab controls now
* live in the "source-control"/"source-control-global" pair, which owns those
* keys outright — the arbitration is no longer needed.
*/
import { GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@fusion/core";
import { GLOBAL_SECTION_KEYS, MODEL_LANE_KEYS } from "./save-split";
export type SettingsResetScope = "global" | "project";
export interface SectionKeyEntry {
scope: SettingsResetScope;
keys: readonly string[];
}
/**
* Project-scope section -> owned key registry. Reuses MODEL_LANE_KEYS from
* save-split.ts for the project-models lane overrides instead of duplicating
* them.
*/
/*
FNXC:UiMetadataApi 2026-07-14-00:00:
Expose project reset-registry ids for the no-drift contract test so a reset-owning section cannot exist without discoverable Settings metadata. This is read-only inspection and does not change reset ownership or behavior.
*/
export const PROJECT_SECTION_KEYS: Readonly<Record<string, readonly string[]>> = {
general: [
"allowAbsoluteFileBrowserPaths",
"capacityRiskBannerEnabled",
"capacityRiskTodoThreshold",
"chatAutoCleanupDays",
"chatRoomCompactionFetchLimit",
"chatRoomRecentVerbatimMessages",
"chatRoomSummaryMaxChars",
"completionDocumentationMode",
"reviewArtifacts",
"enabledBuiltinWorkflowIds",
/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
Owned by "general" because that is where both pickers render. Resetting them writes
null, which restores the unset = "Selected workflow" behavior. `boardSelectedWorkflowId`
is deliberately NOT listed: it is a dashboard-written mirror of the current Board lane,
not an operator-editable field, so a per-menu reset has no business clearing it.
*/
"taskCreateWorkflowId",
"refinementTaskWorkflowId",
"ephemeralAgentTaskCreationPolicy",
"sessionAdvisorEnabledByDefault",
"mailAutoCleanupDays",
"maxRecommendationsPerTask",
"recommendationMailboxNoticeEnabled",
"mobileNavPrimaryItems",
"operationalLogRetentionDays",
"quickChatButtonMode",
"quickChatCloseOnOutsideClick",
"showQuickChatFAB",
"showTaskChatsInCommonFeed",
"taskPrefix",
"workspaceMode",
"reportMode",
"reportModeByAction",
"reportRoadmapDedupeEnabled",
"reportRoadmapLabel",
"reportRoadmapRepo",
],
/*
FNXC:SourceControl 2026-07-15-20:30:
Every project-scoped GitHub/GitLab key is owned here, by the one section that now renders them all. This entry is what replaced the split ownership the notes above used to describe: `gitlabEnabled`'s toggle was rendered from BOTH "general" and "merge", so the registry had to arbitrarily award the key to "general" to keep the per-section reset sets disjoint — a bookkeeping fix for a UI duplicate. With a single owning section the arbitration is gone.
`githubTrackingDefaultRepo` also appears in "source-control-global" (GLOBAL_SECTION_KEYS): it is a real dual-scope key, and the disjointness guard is per-scope, so the project row here and the global row there are two different settings, not a duplicate.
*/
"source-control": [
"githubAuthMode",
"githubAuthToken",
"githubLinkImportedIssuesToTracking",
"githubTrackingDedupEnabled",
"githubTrackingDefaultRepo",
"githubTrackingEnabledByDefault",
"gitlabApiBaseUrl",
"gitlabAuthToken",
"gitlabAuthTokenType",
"gitlabEnabled",
"gitlabInstanceUrl",
],
commands: ["buildCommand", "testCommand"],
worktrees: [
"executorAllowSiblingBranchRename",
"maxWorktrees",
"worktreeLimitEnabled",
"recycleWorktrees",
"showWorktreeGrouping",
"worktreeCopyFiles",
"worktreeInitCommand",
"worktreeNaming",
"worktreeRebaseBeforeMerge",
"worktreeRebaseLocalBase",
"worktreeRebaseRemote",
"worktreesDir",
"worktrunk",
],
scheduling: [
"archiveAgentLogMode",
"autoArchiveDoneAfterMs",
"autoArchiveDoneTasksEnabled",
"engineerBacklogAutoClaim",
"executorToolFailureRetryCount",
"executorToolFailureRetryBackoffMs",
"executorToolFailureThreshold",
"executorModelEscalationEnabled",
"executorEscalationNodeId",
"groupOverlappingFiles",
"heartbeatScopeDiscipline",
"ignoreHiddenOverlapPaths",
"maxConcurrent",
"maxConcurrentVerifications",
"maxStuckKills",
"overlapIgnorePaths",
"pollIntervalMs",
"preserveProgressOnStuckRequeue",
"specStalenessEnabled",
"specStalenessMaxAgeMs",
"staleHighFanoutBlockerAgeThresholdMs",
"taskStuckTimeoutMs",
],
"scheduled-evals": ["evalSettings"],
"node-routing": ["defaultNodeId", "unavailableNodePolicy"],
merge: [
"autoMerge",
"autoResolveConflicts",
"commitAuthorEmail",
"commitAuthorEnabled",
"commitAuthorName",
"directMergeCommitStrategy",
"includeTaskIdInCommit",
"integrationBranch",
"maxAutoMergeRetries",
"mergeAdvanceAutoSync",
"mergeConflictStrategy",
"mergeIntegrationWorktree",
"mergeStrategy",
"githubNativeAutoMerge",
"requiredChecks",
"mergeStrategyOverlapBehavior",
"merger",
"planApprovalMode",
"postMergeAuditMode",
"pushAfterMerge",
"pushRemote",
"smartConflictResolution",
"testMode",
],
"agent-permissions": ["agentProvisioning", "defaultAgentPermissionPolicy"],
backups: [
"memoryBackupDir",
"memoryBackupEnabled",
"memoryBackupRetention",
"memoryBackupSchedule",
"memoryBackupScope",
],
"research-project": ["researchSettings"],
/* FNXC:VoiceInput 2026-07-28-12:00: Voice input is a project preference; reset restores the inherited opt-in default without affecting the local model lifecycle. */
"voice-input": ["voiceInput"],
"project-models": [
"autoSelectModelPreset",
"autoSummarizeTitles",
"defaultPresetBySize",
"taskDefinitionInInputLanguage",
"defaultWorkflowId",
"modelPresets",
"prDescriptionPromptInstructions",
"prTitlePromptInstructions",
"tokenCap",
"useAiMergeCommitSummary",
"executorEscalationProvider",
"executorEscalationModelId",
...MODEL_LANE_KEYS,
],
};
/**
* Non-key sections that are NOT a simple settings blob. Each is managed by
* its own dedicated flow/routes (secrets store, MCP server CRUD, plugin
* manager, memory editor, auth/OAuth, prompt library, CLI adapter approvals,
* plugin runtime config), so a generic "reset to defaults" over the merged
* settings form would be meaningless or actively destructive. Per-menu reset
* is disabled for these with a documented reason (surfaced in the dialog).
*/
export const EXCLUDED_RESET_SECTIONS: Record<string, string> = {
/*
FNXC:SettingsReset 2026-07-15-18:52:
Listed explicitly rather than left to the unknown-id fallback: an unregistered id is reset-ineligible with NO reason, which renders the dialog without telling the operator why the button is unavailable.
*/
secrets: "Secrets are managed by the Secrets store, not the settings form.",
"global-mcp": "MCP servers are managed by their own add/edit/remove flow.",
mcp: "MCP servers are managed by their own add/edit/remove flow.",
plugins: "Plugins and Pi extensions are managed by the Plugin Manager.",
memory: "Memory files are edited directly, not as a settings blob.",
authentication: "Authentication/provider credentials are managed by their own OAuth/API-key flow.",
prompts: "Prompt library entries are managed by their own editor, not bulk-reset here.",
"cli-agents": "Per-adapter CLI agent settings are managed by their own approval/config flow.",
"hermes-runtime": "Runtime plugin settings are managed by the plugin's own config surface.",
"openclaw-runtime": "Runtime plugin settings are managed by the plugin's own config surface.",
"paperclip-runtime": "Runtime plugin settings are managed by the plugin's own config surface.",
};
/**
* Resolve the { scope, keys } entry for a key-owning section id, or `null`
* for excluded/non-key/group-header sections.
*/
export function getSectionKeyEntry(sectionId: string): SectionKeyEntry | null {
/*
FNXC:SettingsReset 2026-07-04-00:10:
Exclusions are checked FIRST because a couple of section ids collide across
the two lookup tables for unrelated reasons: "global-mcp" has an entry in
GLOBAL_SECTION_KEYS (used by splitSettingsSave to gate the normal Save flow)
but is explicitly excluded from RESET because MCP servers are managed by
their own CRUD flow, not a bulk reset. "project-models" also has an entry in
GLOBAL_SECTION_KEYS (its dual-scope global lane baselines) but for reset
purposes only its project-owned keys are touched, so PROJECT_SECTION_KEYS
is checked before GLOBAL_SECTION_KEYS.
*/
if (EXCLUDED_RESET_SECTIONS[sectionId]) {
return null;
}
const projectKeys = PROJECT_SECTION_KEYS[sectionId];
if (projectKeys) {
return { scope: "project", keys: projectKeys };
}
const globalKeys = GLOBAL_SECTION_KEYS[sectionId];
if (globalKeys) {
return { scope: "global", keys: Array.from(globalKeys) };
}
return null;
}
/** True when a section id has no reset-eligible key set (excluded or unknown/group-header). */
export function isResetEligibleSection(sectionId: string): boolean {
return getSectionKeyEntry(sectionId) !== null;
}
/** Human-readable reason a section's per-menu reset is disabled, or undefined if it is eligible. */
export function getResetIneligibleReason(sectionId: string): string | undefined {
return EXCLUDED_RESET_SECTIONS[sectionId];
}
/** Every PROJECT_SETTINGS_KEYS member, used for "reset all project settings". */
export const ALL_PROJECT_RESET_KEYS: readonly string[] = PROJECT_SETTINGS_KEYS;
/** Exposed for tests: validates every registry key against the canonical scope key sets. */
export function isRegistryKeyValidForScope(key: string, scope: SettingsResetScope): boolean {
if (scope === "global") {
return (GLOBAL_SETTINGS_KEYS as readonly string[]).includes(key);
}
return (PROJECT_SETTINGS_KEYS as readonly string[]).includes(key);
}
export { GLOBAL_SECTION_KEYS, MODEL_LANE_KEYS };