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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user