fix: prevent nested .fusion/.fusion dir from PluginStore path bug

PluginStore's constructor treats its rootDir arg as a project root and
internally appends `.fusion` before opening the SQLite DB. Several CLI
call sites were passing the already-resolved `.fusion` directory,
producing a doubled `.fusion/.fusion/fusion.db` that the dashboard
process kept recreating on every project load.

Pass the project root instead so the DB lands in the canonical
`.fusion/fusion.db` alongside the rest of the project's state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-23 15:55:04 -07:00
committed by gsxdsm
parent a1b2d48986
commit 51870ed27b
39 changed files with 1612 additions and 242 deletions

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, validateMessageMetadata } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,

View File

@@ -80,6 +80,44 @@ describe("MessageStore", () => {
expect(message.metadata).toEqual({ taskId: "FN-001", priority: "high" });
});
it("persists reply link metadata through storage roundtrip", () => {
const original = store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Can you help?",
type: "user-to-agent",
});
const reply = store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Sure",
type: "agent-to-user",
metadata: { replyTo: { messageId: original.id } },
});
expect(reply.metadata).toEqual({ replyTo: { messageId: original.id } });
expect(store.getMessage(reply.id)?.metadata).toEqual({ replyTo: { messageId: original.id } });
});
it("rejects malformed reply metadata", () => {
expect(() => {
store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Bad metadata",
type: "agent-to-user",
metadata: { replyTo: { messageId: "" } },
});
}).toThrow("metadata.replyTo.messageId must be a non-empty string");
});
it("returns null for non-existent message", () => {
const result = store.getMessage("msg-nonexistent");
expect(result).toBeNull();

View File

@@ -14,14 +14,7 @@ import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import type { Database } from "./db.js";
import { fromJson, toJsonNullable } from "./db.js";
import type {
Message,
MessageCreateInput,
MessageFilter,
MessageType,
Mailbox,
ParticipantType,
} from "./types.js";
import { validateMessageMetadata, type Message, type MessageCreateInput, type MessageFilter, type MessageType, type Mailbox, type ParticipantType } from "./types.js";
// ── Event Types ─────────────────────────────────────────────────────
@@ -112,7 +105,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
content: row.content,
type: row.type as MessageType,
read: row.read === 1,
metadata: fromJson<Record<string, unknown>>(row.metadata),
metadata: fromJson<Message["metadata"]>(row.metadata),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@@ -126,6 +119,8 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
* @returns The created message
*/
sendMessage(input: MessageCreateInput): Message {
validateMessageMetadata(input.metadata);
const now = new Date().toISOString();
const messageId = `msg-${randomUUID().slice(0, 8)}`;

View File

@@ -29,6 +29,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
favoriteModels: undefined,
openrouterModelSync: true,
modelOnboardingComplete: undefined,
useClaudeCli: undefined,
// Global baseline lanes for per-role model selection
executionGlobalProvider: undefined,
executionGlobalModelId: undefined,

View File

@@ -967,6 +967,17 @@ export interface GlobalSettings {
* false/undefined, the dashboard will auto-open the onboarding modal.
* Also set to true when the user explicitly dismisses onboarding. */
modelOnboardingComplete?: boolean;
/** When true, route AI model calls through the locally-installed Claude CLI
* via the `pi-claude-cli` pi extension (instead of the direct Anthropic
* API). Enabling this also causes Fusion to symlink its skill into each
* project's `.claude/skills/fusion/` on `fn init`, `fn project add`,
* dashboard project creation, and server startup — so the skill is
* available inside Claude Code sessions that pi spawns.
*
* When left undefined, detection falls back to scanning the `packages`
* array in the agent settings for `"npm:pi-claude-cli"` (legacy signal).
* Setting this field explicitly (true/false) always wins. */
useClaudeCli?: boolean;
/** Global baseline AI model provider for task execution (executor agent).
* This is the global lane that project-level `executionProvider` can override.
* Must be set together with `executionGlobalModelId`. Falls back to
@@ -2904,6 +2915,18 @@ export type ParticipantType = "agent" | "user" | "system";
/** Message types/categories */
export type MessageType = "agent-to-agent" | "agent-to-user" | "user-to-agent" | "system";
/** Stable metadata contract for linking a reply to an earlier message. */
export interface MessageReplyReference {
/** ID of the message this one is replying to. */
messageId: string;
}
/** Optional metadata attached to mailbox messages. */
export interface MessageMetadata extends Record<string, unknown> {
/** Optional link to the original message when this message is a reply. */
replyTo?: MessageReplyReference;
}
/** Message record stored in the system */
export interface Message {
/** Unique identifier */
@@ -2923,7 +2946,7 @@ export interface Message {
/** Whether the recipient has read this message */
read: boolean;
/** Optional extra data */
metadata?: Record<string, unknown>;
metadata?: MessageMetadata;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
@@ -2945,7 +2968,7 @@ export interface MessageCreateInput {
/** Message category */
type: MessageType;
/** Optional extra data */
metadata?: Record<string, unknown>;
metadata?: MessageMetadata;
}
/** Filter options for querying messages */
@@ -2960,6 +2983,21 @@ export interface MessageFilter {
offset?: number;
}
/** Validate mailbox metadata, including reply-link contract when present. */
export function validateMessageMetadata(metadata: MessageMetadata | undefined): void {
if (!metadata || metadata.replyTo === undefined) {
return;
}
if (typeof metadata.replyTo !== "object" || metadata.replyTo === null || Array.isArray(metadata.replyTo)) {
throw new Error("metadata.replyTo must be an object");
}
if (typeof metadata.replyTo.messageId !== "string" || metadata.replyTo.messageId.trim().length === 0) {
throw new Error("metadata.replyTo.messageId must be a non-empty string");
}
}
/** Mailbox summary for a participant */
export interface Mailbox {
/** Owner identifier */