FN-7879: deliver one-time Postgres-migration inbox notice on first 0.59 startup

Adds a best-effort, idempotent dashboard inbox notice announcing the upcoming embedded-Postgres storage migration, delivered once per project on the first engine start under the Fusion 0.59.x release line.

- New `deliverPostgresMigrationNoticeIfNeeded` in `@fusion/engine` (`postgres-migration-notice.ts`) builds and sends a `system` -> `user` inbox message via `MessageStore`, gated to version `0.59.x` by `isPostgresMigrationNoticeVersion`
- Idempotency via existing inbox message `metadata.kind = "postgres-migration-notice"` marker (no new settings key or table), so restarts never duplicate the notice
- Delivery is fully best-effort: any `MessageStore` failure is caught, logged as a warning, and never blocks or fails `ProjectEngine.start()`
- `ProjectEngine.start()` invokes the notice after runtime start, using an injected `cliPackageVersion` threaded from the CLI layer through `EngineManagerOptions` / `ProjectEngineOptions` so the engine never imports CLI/dashboard code directly
- `daemon.ts`, `dashboard.ts`, and `serve.ts` resolve the published `@runfusion/fusion` version via `getCliPackageVersion` / `isUnresolvedCliPackageVersion` and pass it into `ProjectEngineManager`
- Exported new symbols (`POSTGRES_MIGRATION_HELP_URL`, `POSTGRES_MIGRATION_NOTICE_KIND`, `deliverPostgresMigrationNoticeIfNeeded`, `isPostgresMigrationNoticeVersion`, related types) from `@fusion/engine`, and `isUnresolvedCliPackageVersion` from `@fusion/dashboard`
- New unit tests covering version matching and single-delivery/idempotency behavior
- Docs updated (`docs/agents.md`, `docs/dashboard-guide.md`) to describe the one-time notice and its dedup key
- Changeset added for `@runfusion/fusion` (minor, feature)

Files changed:
 .changeset/fn-7879-postgres-migration-inbox-notice.md              |   7 ++
 docs/agents.md                                                     |   1 +
 docs/dashboard-guide.md                                            |   1 +
 packages/cli/src/commands/daemon.ts                                |   6 +-
 packages/cli/src/commands/dashboard.ts                             |   5 +
 packages/cli/src/commands/serve.ts                                 |   6 +-
 packages/dashboard/src/index.ts                                    |   2 +-
 packages/engine/src/__tests__/postgres-migration-notice.test.ts    | 140 +++++++++++++++++++++
 packages/engine/src/index.ts                                       |   9 ++
 packages/engine/src/postgres-migration-notice.ts                   | 107 ++++++++++++++++
 packages/engine/src/project-engine-manager.ts                      |   6 +
 packages/engine/src/project-engine.ts                               |  12 ++
 12 files changed, 299 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-7879

Fusion-Task-Lineage: 201877e5-6bdc-4168-a8ac-ae0e50ec8308

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 17:02:33 -07:00
parent 56c745240a
commit c745990aa2
12 changed files with 299 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Deliver a one-time inbox notice about the upcoming Postgres storage migration on first 0.59 startup.
category: feature
dev: New best-effort, idempotent `deliverPostgresMigrationNoticeIfNeeded` in `@fusion/engine`, invoked from `ProjectEngine.start()`; gated to version `0.59.x` via injected `cliPackageVersion` (threaded through `EngineManagerOptions`/`ProjectEngineOptions`); idempotency via inbox `metadata.kind = "postgres-migration-notice"` marker. Links to Discord (`https://discord.gg/ksrfuy7WYR`).

View File

@@ -49,6 +49,7 @@ Artifact tools operate on the shared artifact registry, so artifacts are visible
- `fn_artifact_list` lists artifacts across agents and tasks with optional `type`, `authorId`, `taskId`, `search`, `limit`, and `offset` filters. Dashboard chat's scoped variant requires `task_id` and otherwise supports `type`, `authorId`, `search`, `limit`, and `offset` for that task.
- `fn_artifact_view` fetches one artifact by `id`, returning registry metadata plus inline `content` when present or the stored `uri`/path reference for media artifacts.
- Successful registration emits a best-effort `system` → `user` inbox notification to `DASHBOARD_USER_ID` with `artifactId`, `artifactType`, `title`, `authorId`, and optional `taskId` metadata. Notification delivery failures are logged and must never fail or roll back the artifact registration.
- On first startup under Fusion `0.59.x`, the engine also sends one best-effort `system` → `user` inbox notice per project about the upcoming embedded-Postgres storage migration, keyed by `metadata.kind = "postgres-migration-notice"` so restarts do not duplicate it.
For the user-facing gallery and notification UX, see [Artifacts View](./dashboard-guide.md#artifacts-view) and [Mailbox View](./dashboard-guide.md#mailbox-view). For storage layout and hydration semantics, see [Artifact registry](./storage.md#artifact-registry-fn-6777).

View File

@@ -619,6 +619,7 @@ Mailbox view shows inbox/outbox communication threads and unread state.
- clicking a message in the Mail tab opens the task detail pane with full message content and conversation context
- reply rows in the mailbox modal can expand inline to show the replied-to message context for easier thread reading
- when an agent or dashboard chat session registers an artifact with `fn_artifact_register`, Fusion sends a best-effort `system` → user inbox message announcing the new artifact (for example, `New image artifact registered: <title>`) with metadata for `artifactId`, `artifactType`, `title`, optional `mimeType`, `authorId`, and optional `taskId`; notification delivery is informational and never blocks or rolls back the artifact registration. Artifact notifications are actionable in message detail views: image artifacts show an inline preview plus **Open artifact**, while video/audio/document/other artifacts show an **Open artifact** link to the managed media URL.
- on first engine startup under Fusion `0.59.x`, each project receives one best-effort `system` inbox notice about the upcoming embedded-Postgres storage migration with the Discord help link; `metadata.kind = "postgres-migration-notice"` prevents duplicates across restarts.
- mailbox now includes an **Approvals** tab with pending and history filters (`approved` / `denied` / `completed`), approval detail context, and inline approve/deny actions for pending requests
- for approvals gated by an agent's permission policy (permanent agents and task-worker heartbeats), the Approvals detail pane renders the gated action's real payload — tool name, shell command line or structured arguments, and working directory when present — instead of only a generic "Agent gated action for `<tool>`" summary; a stateless heartbeat retrying the same gated command reuses the existing pending approval instead of creating a duplicate (FN-7609)
- in the **Agents** tab, the agent selector now includes **All agents**, which shows one combined agent-to-agent stream (with sender + recipient labels); selecting a specific agent still shows Inbox/Outbox subtabs

View File

@@ -29,7 +29,7 @@ import {
registerBuiltInZaiProvider,
} from "@fusion/core";
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, refreshAllCustomProviderModels, registerGithubTrackingHook } from "@fusion/dashboard";
import { createServer, GitHubClient, createSkillsAdapter, getCliPackageVersion, getProjectSettingsPath, isUnresolvedCliPackageVersion, loadTlsCredentialsFromEnv, refreshAllCustomProviderModels, registerGithubTrackingHook } from "@fusion/dashboard";
import {
ProjectEngineManager,
PeerExchangeService,
@@ -341,7 +341,11 @@ export async function runDaemon(opts: DaemonOptions = {}) {
// Some tests partially mock @fusion/dashboard and omit this export.
}
const resolvedCliPackageVersion = getCliPackageVersion(import.meta.url);
const cliPackageVersion = isUnresolvedCliPackageVersion(resolvedCliPackageVersion) ? undefined : resolvedCliPackageVersion;
const engineManager = new ProjectEngineManager(sharedCentralCore, {
cliPackageVersion,
getMergeStrategy,
processPullRequestMerge: (s, wd, taskId, pool) =>
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool),

View File

@@ -42,6 +42,7 @@ import {
GitHubClient,
createSkillsAdapter,
getCliPackageVersion,
isUnresolvedCliPackageVersion,
getProjectSettingsPath,
loadTlsCredentialsFromEnv,
registerGithubTrackingHook,
@@ -1854,7 +1855,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Some tests partially mock @fusion/dashboard and omit this export.
}
const resolvedCliPackageVersion = getCliPackageVersion(import.meta.url);
const cliPackageVersion = isUnresolvedCliPackageVersion(resolvedCliPackageVersion) ? undefined : resolvedCliPackageVersion;
const engineManager = new ProjectEngineManager(centralCoreForEngine, {
cliPackageVersion,
getMergeStrategy,
processPullRequestMerge: (s, wd, taskId, pool) =>
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool),

View File

@@ -29,7 +29,7 @@ import {
registerBuiltInZaiProvider,
} from "@fusion/core";
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, refreshAllCustomProviderModels, registerGithubTrackingHook } from "@fusion/dashboard";
import { createServer, GitHubClient, createSkillsAdapter, getCliPackageVersion, getProjectSettingsPath, isUnresolvedCliPackageVersion, loadTlsCredentialsFromEnv, refreshAllCustomProviderModels, registerGithubTrackingHook } from "@fusion/dashboard";
import {
ProjectEngineManager,
PeerExchangeService,
@@ -366,7 +366,11 @@ export async function runServe(
// Some tests partially mock @fusion/dashboard and omit this export.
}
const resolvedCliPackageVersion = getCliPackageVersion(import.meta.url);
const cliPackageVersion = isUnresolvedCliPackageVersion(resolvedCliPackageVersion) ? undefined : resolvedCliPackageVersion;
const engineManager = new ProjectEngineManager(sharedCentralCore, {
cliPackageVersion,
getMergeStrategy,
processPullRequestMerge: (s, wd, taskId, pool) =>
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool),

View File

@@ -112,7 +112,7 @@ export {
export { GitHubTrackingCommentService, formatTrackingComment } from "./github-tracking-comments.js";
export { GitHubTrackingStateService, decideIssueAction } from "./github-tracking-state.js";
export { GitHubTrackingReconciler, RECONCILE_CONCURRENCY_LIMIT, RECONCILE_SCAN_LIMIT } from "./github-tracking-reconciler.js";
export { getCliPackageVersion, resolveCliPackageVersionInfo, type CliPackageVersionInfo } from "./cli-package-version.js";
export { getCliPackageVersion, isUnresolvedCliPackageVersion, resolveCliPackageVersionInfo, type CliPackageVersionInfo } from "./cli-package-version.js";
export {
ApiError,
type ApiErrorResponse,

View File

@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DASHBOARD_USER_ID, Database, MessageStore, type MessageStore as MessageStoreType } from "@fusion/core";
import {
POSTGRES_MIGRATION_HELP_URL,
POSTGRES_MIGRATION_NOTICE_KIND,
deliverPostgresMigrationNoticeIfNeeded,
isPostgresMigrationNoticeVersion,
} from "../postgres-migration-notice.js";
describe("postgres migration notice", () => {
let db: Database;
let store: MessageStore;
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "kb-postgres-notice-test-"));
db = new Database(tempDir, { inMemory: true });
db.init();
store = new MessageStore(db);
});
afterEach(() => {
vi.restoreAllMocks();
db.close();
rmSync(tempDir, { recursive: true, force: true });
});
it("matches only the 0.59 release line", () => {
expect(isPostgresMigrationNoticeVersion("0.59.0")).toBe(true);
expect(isPostgresMigrationNoticeVersion("0.59.3")).toBe(true);
expect(isPostgresMigrationNoticeVersion("0.59.10")).toBe(true);
expect(isPostgresMigrationNoticeVersion(undefined)).toBe(false);
expect(isPostgresMigrationNoticeVersion("")).toBe(false);
expect(isPostgresMigrationNoticeVersion("0.58.0")).toBe(false);
expect(isPostgresMigrationNoticeVersion("0.60.0")).toBe(false);
expect(isPostgresMigrationNoticeVersion("1.59.0")).toBe(false);
expect(isPostgresMigrationNoticeVersion("0.0.0")).toBe(false);
expect(isPostgresMigrationNoticeVersion("0.0.0-dev")).toBe(false);
expect(isPostgresMigrationNoticeVersion("not-semver")).toBe(false);
});
it("delivers exactly one system inbox notice for 0.59.x", async () => {
const result = await deliverPostgresMigrationNoticeIfNeeded({
messageStore: store,
version: "0.59.3",
});
expect(result).toBe("delivered");
const inbox = store.getInbox(DASHBOARD_USER_ID, "user", { type: "system" });
expect(inbox).toHaveLength(1);
expect(inbox[0]).toEqual(expect.objectContaining({
fromType: "system",
toType: "user",
toId: DASHBOARD_USER_ID,
type: "system",
metadata: expect.objectContaining({
kind: POSTGRES_MIGRATION_NOTICE_KIND,
version: "0.59.3",
helpUrl: POSTGRES_MIGRATION_HELP_URL,
}),
}));
expect(inbox[0]?.content).toContain("Postgres backend for data storage");
expect(inbox[0]?.content).toContain(POSTGRES_MIGRATION_HELP_URL);
});
it("is idempotent across restart-like repeated calls", async () => {
await expect(deliverPostgresMigrationNoticeIfNeeded({
messageStore: store,
version: "0.59.0",
})).resolves.toBe("delivered");
await expect(deliverPostgresMigrationNoticeIfNeeded({
messageStore: store,
version: "0.59.0",
})).resolves.toBe("already-delivered");
const inbox = store.getInbox(DASHBOARD_USER_ID, "user", { type: "system" });
expect(inbox).toHaveLength(1);
});
it("does not deliver for version mismatches", async () => {
await expect(deliverPostgresMigrationNoticeIfNeeded({
messageStore: store,
version: "0.58.0",
})).resolves.toBe("version-mismatch");
await expect(deliverPostgresMigrationNoticeIfNeeded({
messageStore: store,
version: undefined,
})).resolves.toBe("version-mismatch");
expect(store.getInbox(DASHBOARD_USER_ID, "user", { type: "system" })).toHaveLength(0);
});
it("returns no-store when the message store is unavailable", async () => {
await expect(deliverPostgresMigrationNoticeIfNeeded({
messageStore: undefined,
version: "0.59.0",
})).resolves.toBe("no-store");
});
it("swallows getInbox failures and logs a warning", async () => {
const warn = vi.fn();
const throwingStore = {
getInbox: vi.fn(() => {
throw new Error("inbox unavailable");
}),
sendMessage: vi.fn(),
} as unknown as MessageStoreType;
await expect(deliverPostgresMigrationNoticeIfNeeded({
messageStore: throwingStore,
version: "0.59.0",
log: { warn },
})).resolves.toBe("already-delivered");
expect(warn).toHaveBeenCalledWith(expect.stringContaining("inbox unavailable"));
});
it("swallows sendMessage failures and logs a warning", async () => {
const warn = vi.fn();
const throwingStore = {
getInbox: vi.fn(() => []),
sendMessage: vi.fn(() => {
throw new Error("send unavailable");
}),
} as unknown as MessageStoreType;
await expect(deliverPostgresMigrationNoticeIfNeeded({
messageStore: throwingStore,
version: "0.59.0",
log: { warn },
})).resolves.toBe("already-delivered");
expect(warn).toHaveBeenCalledWith(expect.stringContaining("send unavailable"));
});
});

View File

@@ -70,6 +70,15 @@ export {
traitListParams,
executeApprovedAgentProvisioning,
} from "./agent-tools.js";
export {
POSTGRES_MIGRATION_HELP_URL,
POSTGRES_MIGRATION_NOTICE_KIND,
deliverPostgresMigrationNoticeIfNeeded,
isPostgresMigrationNoticeVersion,
type DeliverPostgresMigrationNoticeArgs,
type PostgresMigrationNoticeLog,
type PostgresMigrationNoticeResult,
} from "./postgres-migration-notice.js";
export { AgentSemaphore, PRIORITY_MERGE, PRIORITY_EXECUTE, PRIORITY_SPECIFY } from "./concurrency.js";
export { TriageProcessor, type TriageProcessorOptions } from "./triage.js";
export { TaskExecutor, type TaskExecutorOptions } from "./executor.js";

View File

@@ -0,0 +1,107 @@
import { DASHBOARD_USER_ID, type MessageStore } from "@fusion/core";
export const POSTGRES_MIGRATION_NOTICE_KIND = "postgres-migration-notice";
export const POSTGRES_MIGRATION_HELP_URL = "https://discord.gg/ksrfuy7WYR";
export type PostgresMigrationNoticeResult = "delivered" | "already-delivered" | "version-mismatch" | "no-store";
export interface PostgresMigrationNoticeLog {
warn(message: string): void;
log?(message: string): void;
}
export interface DeliverPostgresMigrationNoticeArgs {
messageStore: MessageStore | undefined;
version: string | undefined;
log?: PostgresMigrationNoticeLog;
}
const POSTGRES_MIGRATION_NOTICE_TITLE = "Storage update coming in the next Fusion version";
const POSTGRES_MIGRATION_NOTICE_BODY = "The next Fusion version will replace the current SQLite data store with an embedded Postgres backend for data storage, and project databases will be served from the central Fusion database instead of each project's local .fusion/fusion.db SQLite file. No migration runs from this notice; it is an advance heads-up for operators who rely on the current storage layout.";
/*
FNXC:StorageMigrationNotice 2026-07-12-00:00:
Fusion 0.59.x is the only release line that should deliver the durable Postgres-migration heads-up: it is the first-start announcement window before the storage backend change, while dev/unresolved sentinels and all other versions must stay silent so local source builds and later restarts do not surprise operators.
*/
export function isPostgresMigrationNoticeVersion(version: string | undefined): boolean {
const trimmed = version?.trim();
if (!trimmed) return false;
const match = /^(\d+)\.(\d+)\.(\d+)(?:[+-].*)?$/.exec(trimmed);
if (!match) return false;
const major = Number.parseInt(match[1] ?? "", 10);
const minor = Number.parseInt(match[2] ?? "", 10);
const patch = Number.parseInt(match[3] ?? "", 10);
if (!Number.isSafeInteger(major) || !Number.isSafeInteger(minor) || !Number.isSafeInteger(patch)) {
return false;
}
if (major === 0 && minor === 0 && patch === 0) {
return false;
}
return major === 0 && minor === 59;
}
function buildPostgresMigrationNoticeContent(): string {
return [
POSTGRES_MIGRATION_NOTICE_TITLE,
"",
POSTGRES_MIGRATION_NOTICE_BODY,
"",
`Need help? Join us on Discord: ${POSTGRES_MIGRATION_HELP_URL}`,
].join("\n");
}
/*
FNXC:StorageMigrationNotice 2026-07-12-00:00:
The startup notice is per project because each project has its own dashboard mailbox. Idempotency uses the durable system-inbox metadata.kind marker instead of a new settings key or marker table: the mailbox survives engine restarts and is the operator-visible record we are protecting from duplication.
FNXC:StorageMigrationNotice 2026-07-12-00:00:
Delivery is best-effort by design. ProjectEngine.start() must never fail or stall because an informational support message could not be queried or written, so every MessageStore interaction is contained here and downgraded to a warning plus a non-delivered result.
*/
export async function deliverPostgresMigrationNoticeIfNeeded({
messageStore,
version,
log,
}: DeliverPostgresMigrationNoticeArgs): Promise<PostgresMigrationNoticeResult> {
let fallbackResult: PostgresMigrationNoticeResult = "version-mismatch";
try {
if (!messageStore) {
return "no-store";
}
fallbackResult = "no-store";
if (!isPostgresMigrationNoticeVersion(version)) {
return "version-mismatch";
}
fallbackResult = "already-delivered";
const inbox = messageStore.getInbox(DASHBOARD_USER_ID, "user", { type: "system" });
if (inbox.some((message) => message.metadata?.kind === POSTGRES_MIGRATION_NOTICE_KIND)) {
return "already-delivered";
}
messageStore.sendMessage({
fromType: "system",
toType: "user",
toId: DASHBOARD_USER_ID,
type: "system",
content: buildPostgresMigrationNoticeContent(),
metadata: {
kind: POSTGRES_MIGRATION_NOTICE_KIND,
version,
helpUrl: POSTGRES_MIGRATION_HELP_URL,
},
});
log?.log?.("Delivered Postgres migration dashboard inbox notice");
return "delivered";
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log?.warn(`Postgres migration inbox notice delivery failed (continuing startup): ${message}`);
return fallbackResult;
}
}

View File

@@ -34,6 +34,11 @@ import { runtimeLog } from "./logger.js";
* These are injected by the CLI layer (dashboard.ts / serve.ts).
*/
export interface EngineManagerOptions {
/**
* FNXC:StorageMigrationNotice 2026-07-12-00:00:
* The manager carries the resolved CLI package version to each per-project engine so the one-time Postgres-migration inbox message is evaluated per project while remaining gated to the same released runtime version.
*/
cliPackageVersion?: ProjectEngineOptions["cliPackageVersion"];
getMergeStrategy?: ProjectEngineOptions["getMergeStrategy"];
processPullRequestMerge?: ProjectEngineOptions["processPullRequestMerge"];
createGroupPr?: ProjectEngineOptions["createGroupPr"];
@@ -533,6 +538,7 @@ export class ProjectEngineManager {
): ProjectEngineOptions {
return {
projectId: project.id,
cliPackageVersion: this.options.cliPackageVersion,
getMergeStrategy: this.options.getMergeStrategy,
processPullRequestMerge: this.options.processPullRequestMerge,
createGroupPr: this.options.createGroupPr,

View File

@@ -75,6 +75,7 @@ import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
import { isTransientError } from "./transient-error-detector.js";
import { classifyTransientMergeError } from "./transient-merge-error-classifier.js";
import { TunnelProcessManager } from "./remote-access/tunnel-process-manager.js";
import { deliverPostgresMigrationNoticeIfNeeded } from "./postgres-migration-notice.js";
import type {
ExternalTunnelInfo,
TunnelProvider,
@@ -261,6 +262,11 @@ export interface ProjectEngineOptions {
projectId?: string;
/** Base URL for ntfy.sh notifications */
ntfyBaseUrl?: string;
/**
* FNXC:StorageMigrationNotice 2026-07-12-00:00:
* The CLI layer injects the resolved published @runfusion/fusion version so startup-only operator notices can be gated to release lines without importing dashboard/CLI code into the engine. When absent or unresolved, the Postgres-migration inbox notice is skipped safely.
*/
cliPackageVersion?: string;
/**
* An already-initialized TaskStore to use instead of creating a new one.
* When provided, InProcessRuntime will skip TaskStore construction and init().
@@ -613,6 +619,12 @@ export class ProjectEngine {
// 1. Start the core runtime (TaskStore, Scheduler, Executor, Triage, etc.)
await this.runtime.start();
await deliverPostgresMigrationNoticeIfNeeded({
messageStore: this.runtime.getMessageStore(),
version: this.options.cliPackageVersion,
log: runtimeLog,
});
const store = this.runtime.getTaskStore();
const cwd = this.config.workingDirectory;
const settings = await store.getSettings();