diff --git a/App/backend/src/index.ts b/App/backend/src/index.ts index ced17a4f7..796399e22 100644 --- a/App/backend/src/index.ts +++ b/App/backend/src/index.ts @@ -101,9 +101,15 @@ export async function createLocalBackend(options: CreateLocalBackendOptions): Pr }); await ensureMemoryScanPreferences( memmyConfigPath, - appStateStore.repositories.bootstrap.getScanPreferences() + appStateStore.repositories.bootstrap.getScanPreferences(), + appStateStore.repositories.bootstrap.getOnboardingState().scanPermission ); const scanPreferencesStore = createMemoryScanPreferencesStore(memmyConfigPath); + // Recover a permission save interrupted between config publication and DB update. + const scanPermission = scanPreferencesStore.getScanPermission(); + if (scanPermission !== undefined && scanPermission !== appStateStore.repositories.bootstrap.getOnboardingState().scanPermission) { + appStateStore.repositories.bootstrap.updateOnboarding({ scanPermission }); + } const permissionManager = createPermissionManager({ appStateStore, diff --git a/App/backend/src/infrastructure/memmy-config/agent-access.ts b/App/backend/src/infrastructure/memmy-config/agent-access.ts index d2fd9abc5..65bf758ca 100644 --- a/App/backend/src/infrastructure/memmy-config/agent-access.ts +++ b/App/backend/src/infrastructure/memmy-config/agent-access.ts @@ -1,11 +1,13 @@ import { readFileSync } from "node:fs"; -import type { PatchScanPreferencesInput, ScanPreferences } from "@memmy/local-api-contracts"; +import type { PatchScanPreferencesInput, ScanPreferences, ScanPermission } from "@memmy/local-api-contracts"; import { ScanPreferencesSchema } from "@memmy/local-api-contracts"; import { mutateRuntimeConfig } from "@memmy/migrations"; import YAML from "yaml"; export interface ScanPreferencesStore { getScanPreferences(): ScanPreferences; + getScanPermission(): ScanPermission | undefined; + setScanPermission(permission: ScanPermission, enableAutomation?: boolean): Promise; updateScanPreferences(patch: PatchScanPreferencesInput): Promise; } @@ -17,16 +19,19 @@ export const DEFAULT_MEMORY_SCAN_PREFERENCES: ScanPreferences = { export async function ensureMemoryScanPreferences( configPath: string, - legacyPreferences: ScanPreferences + legacyPreferences: ScanPreferences, + scanPermission?: ScanPermission ): Promise { await mutateRuntimeConfig(configPath, (root) => { const memory = record(root.memmyMemory); - if (isCompletePreferences(memory.agentAccess)) return; + const access = record(memory.agentAccess); + if (isCompletePreferences(access) && (scanPermission === undefined || access.scanPermission !== undefined)) return; root.memmyMemory = { ...memory, agentAccess: { ...legacyPreferences, - ...record(memory.agentAccess) + ...access, + ...(scanPermission !== undefined && access.scanPermission === undefined ? { scanPermission } : {}) } }; }); @@ -34,6 +39,37 @@ export async function ensureMemoryScanPreferences( export function createMemoryScanPreferencesStore(configPath: string): ScanPreferencesStore { return { + async setScanPermission(permission, enableAutomation = false) { + await mutateRuntimeConfig(configPath, (root) => { + const memory = record(root.memmyMemory); + root.memmyMemory = { + ...memory, + agentAccess: { + ...record(memory.agentAccess), + scanPermission: permission, + ...(permission === "none" || permission === "unset" + ? { autoScanKnownAgents: false, watchFileChanges: false, autoInjectSkill: false } + : enableAutomation ? { + autoScanKnownAgents: true, + watchFileChanges: true, + autoInjectSkill: permission === "scan_and_write_skill" + } : {}) + } + }; + }); + }, + getScanPermission() { + try { + const root = record(YAML.parse(readFileSync(configPath, "utf8"))); + const permission = record(record(root.memmyMemory).agentAccess).scanPermission; + if (permission === undefined) return undefined; + return permission === "none" || permission === "scan_only" || permission === "scan_and_write_skill" + ? permission : "unset"; + } catch (error) { + if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + }, getScanPreferences() { return readMemoryScanPreferences(configPath); }, @@ -44,7 +80,7 @@ export function createMemoryScanPreferencesStore(configPath: string): ScanPrefer root.memmyMemory = { ...memory, agentAccess: { - ...readPreferencesRecord(memory.agentAccess), + ...record(memory.agentAccess), ...patch } }; diff --git a/App/backend/src/services/app-config-service.ts b/App/backend/src/services/app-config-service.ts index ec2330727..ed010b92d 100644 --- a/App/backend/src/services/app-config-service.ts +++ b/App/backend/src/services/app-config-service.ts @@ -96,6 +96,12 @@ type ResolvedModelConfigTestInput = ModelConfigTestInput & { apiKey: string }; /** Creates create app config service. */ export function createAppConfigService(options: CreateAppConfigServiceOptions): AppConfigService { const modelConfigTester = options.modelConfigTester ?? createHttpModelConfigTester(); + let scanSettingsWrite = Promise.resolve(); + function serializeScanSettings(operation: () => Promise): Promise { + const result = scanSettingsWrite.then(operation); + scanSettingsWrite = result.then(() => undefined, () => undefined); + return result; + } return { async updateSettings(input) { @@ -137,13 +143,21 @@ export function createAppConfigService(options: CreateAppConfigServiceOptions): }, async updateScanPreferences(input) { - return options.scanPreferencesStore + return serializeScanSettings(async () => options.scanPreferencesStore ? options.scanPreferencesStore.updateScanPreferences(input) - : options.bootstrapRepository.updateScanPreferences(input); + : options.bootstrapRepository.updateScanPreferences(input)); }, async updateOnboarding(input) { - return options.bootstrapRepository.updateOnboarding(input); + return serializeScanSettings(async () => { + // Publish the authorization and automation switches together before advancing + // the guide. A later failure leaves the durable refusal in place for restart. + if (input.scanPermission !== undefined) { + await options.scanPreferencesStore?.setScanPermission(input.scanPermission, input.currentStep === "scan_permission_required"); + await options.memoryClient?.reloadConfig({ reason: "scan_permission_saved" }); + } + return options.bootstrapRepository.updateOnboarding(input); + }); }, async setImprovementProgram(input) { diff --git a/App/backend/src/services/tests/onboarding-scan-permission.integration.test.ts b/App/backend/src/services/tests/onboarding-scan-permission.integration.test.ts new file mode 100644 index 000000000..a238c3af9 --- /dev/null +++ b/App/backend/src/services/tests/onboarding-scan-permission.integration.test.ts @@ -0,0 +1,156 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, expect, it, vi } from "vitest"; +import YAML from "yaml"; +import { createAppStateStore } from "../../infrastructure/app-state-store/index.js"; +import { createMemoryScanPreferencesStore, ensureMemoryScanPreferences } from "../../infrastructure/memmy-config/agent-access.js"; +import { createAppConfigService } from "../app-config-service.js"; +import { createAgentSourceExecutor } from "../../../../../Memory/src/agent-source/runtime.js"; +import { createSourceRegistry } from "../../../../../Memory/src/agent-source/adapters/source-registry.js"; +import type { MemoryService } from "../../../../../Memory/src/service/memory-service.js"; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); vi.restoreAllMocks(); }); +const deny = { scanPermission: "none", currentStep: "product_tour_required", firstEncounterReportStatus: "skipped" } as const; + +async function fixture() { + const root = mkdtempSync(join(tmpdir(), "memmy-onboarding-permission-")); + const configPath = join(root, "config.yaml"); + const store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + cleanups.push(async () => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const bootstrapRepository = store.repositories.bootstrap; + await ensureMemoryScanPreferences(configPath, bootstrapRepository.getScanPreferences(), "unset"); + const preferences = createMemoryScanPreferencesStore(configPath); + const detect = vi.fn(async () => true); + const scan = vi.fn(); + const addMemory = vi.fn(() => ({ id: "fixture", duplicate: false })); + const makeExecutor = () => { + const executor = createAgentSourceExecutor({ + service: { addMemory, enqueuePendingImportSummaries: vi.fn(), clearAllData: vi.fn(() => ({ deleted: true })) } as unknown as MemoryService, + configPath, statePath: join(root, "sources.json"), initialScanDelayMs: 1, scheduledScanIntervalMs: 10, + sourceRegistry: createSourceRegistry([{ + descriptor: { sourceId: "fixture", displayName: "Fixture", builtin: true, dataPath: root }, detect, + async *scan() { scan(); yield { sourceId: "fixture", messageId: "one", conversationId: "conversation", role: "user", content: "Synthetic onboarding regression", createdAt: "2026-10-23T00:00:00Z", workspacePath: null, gitRoot: null, rawMeta: {} }; yield { sourceId: "fixture", messageId: "two", conversationId: "conversation", role: "assistant", content: "Recorded", createdAt: "2026-10-23T00:01:00Z", workspacePath: null, gitRoot: null, rawMeta: {} }; } + }]) + }); + cleanups.push(async () => { await executor.dispose(); }); + return executor; + }; + const executor = makeExecutor(); + const reloadConfig = vi.fn(async () => { await executor.reconcileScanPermission!(); return {} as never; }); + const service = createAppConfigService({ bootstrapRepository, scanPreferencesStore: preferences, memoryClient: { reloadConfig } }); + const access = () => YAML.parse(readFileSync(configPath, "utf8")).memmyMemory.agentAccess; + return { service, bootstrapRepository, preferences, executor, makeExecutor, reloadConfig, configPath, detect, scan, addMemory, access }; +} + +it("refusal disables automation and survives a new executor even if toggles are later enabled", async () => { + const f = await fixture(); + await f.service.updateOnboarding(deny); + expect(f.access()).toMatchObject({ scanPermission: "none", autoScanKnownAgents: false, watchFileChanges: false, autoInjectSkill: false }); + expect(f.bootstrapRepository.getOnboardingState()).toMatchObject(deny); + await f.preferences.updateScanPreferences({ autoScanKnownAgents: true, watchFileChanges: true }); + await f.executor.dispose(); + const restarted = f.makeExecutor(); + restarted.startAutomation(); + await new Promise(resolve => setTimeout(resolve, 60)); + expect(f.detect).not.toHaveBeenCalled(); + await expect(restarted.startScan({ sourceId: "all" })).rejects.toThrow("not authorized"); + expect(f.addMemory).not.toHaveBeenCalled(); +}); + +it("failed config persistence does not advance onboarding and can be retried", async () => { + const f = await fixture(); + vi.spyOn(f.preferences, "setScanPermission").mockRejectedValueOnce(new Error("disk full")); + await expect(f.service.updateOnboarding(deny)).rejects.toThrow("disk full"); + expect(f.bootstrapRepository.getOnboardingState().currentStep).toBe("scan_permission_required"); + expect(f.reloadConfig).not.toHaveBeenCalled(); + await f.service.updateOnboarding(deny); + expect(f.bootstrapRepository.getOnboardingState()).toMatchObject(deny); +}); + +it.each(["reload", "database"])("a %s failure after config publication remains denied after restart and supports retry", async failure => { + const f = await fixture(); + if (failure === "reload") f.reloadConfig.mockRejectedValueOnce(new Error("offline")); + else vi.spyOn(f.bootstrapRepository, "updateOnboarding").mockImplementationOnce(() => { throw new Error("database failure"); }); + await expect(f.service.updateOnboarding(deny)).rejects.toThrow(); + expect(f.bootstrapRepository.getOnboardingState().currentStep).toBe("scan_permission_required"); + await ensureMemoryScanPreferences(f.configPath, f.bootstrapRepository.getScanPreferences(), f.bootstrapRepository.getOnboardingState().scanPermission); + await f.executor.dispose(); + const restarted = f.makeExecutor(); + restarted.startAutomation(); + await new Promise(resolve => setTimeout(resolve, 60)); + expect(f.preferences.getScanPermission()).toBe("none"); + expect(f.access().scanPermission).toBe("none"); + expect(f.scan).not.toHaveBeenCalled(); + await f.service.updateOnboarding(deny); + expect(f.bootstrapRepository.getOnboardingState()).toMatchObject(deny); +}); + +it("refusal during an active scan waits for cancellation and prevents history imports", async () => { + const f = await fixture(); + await f.service.updateOnboarding({ scanPermission: "scan_only" }); + let release!: () => void; + f.detect.mockImplementationOnce(() => new Promise(resolve => { release = () => resolve(true); })); + await f.executor.startScan({ sourceId: "all" }); + await vi.waitFor(() => expect(f.detect).toHaveBeenCalled()); + let saved = false; + const saving = f.service.updateOnboarding(deny).then(() => { saved = true; }); + try { + await vi.waitFor(() => expect(f.access().scanPermission).toBe("none")); + expect(saved).toBe(false); + release(); + await saving; + expect(f.executor.scanStatus().running).toBe(false); + expect(f.addMemory).not.toHaveBeenCalled(); + await expect(f.executor.startScan({ sourceId: "all" })).rejects.toThrow("not authorized"); + } finally { release(); await saving; } +}); + +it("allowed onboarding still scans and imports a synthetic history", async () => { + const f = await fixture(); + await f.service.updateOnboarding({ scanPermission: "scan_only" }); + await f.executor.startScan({ sourceId: "all" }); + await vi.waitFor(() => expect(f.executor.scanStatus().running).toBe(false)); + expect(f.executor.scanStatus().error).toBeNull(); + expect(f.addMemory).toHaveBeenCalledOnce(); +}); + + +it("denial does not block clearing local memory", async () => { + const f = await fixture(); + await f.service.updateOnboarding(deny); + await expect(f.executor.clearAllData()).resolves.toEqual({ deleted: true }); +}); + +it("denial also cancels a paused scan and repeated refusal is idempotent", async () => { + const f = await fixture(); + await f.service.updateOnboarding({ scanPermission: "scan_only" }); + let release!: () => void; + f.detect.mockImplementationOnce(() => new Promise(resolve => { release = () => resolve(true); })); + await f.executor.startScan({ sourceId: "all" }); + await vi.waitFor(() => expect(f.detect).toHaveBeenCalled()); + await f.executor.pauseScan(); + const saving = f.service.updateOnboarding(deny); + try { + await vi.waitFor(() => expect(f.access().scanPermission).toBe("none")); + release(); + await saving; + await f.service.updateOnboarding(deny); + expect(f.executor.scanStatus()).toMatchObject({ running: false, status: "cancelled" }); + expect(f.addMemory).not.toHaveBeenCalled(); + } finally { release(); await saving; } +}); + + +it("explicit manual reauthorization preserves disabled automation; onboarding grant enables it", async () => { + const f = await fixture(); + await f.service.updateOnboarding(deny); + await f.service.updateOnboarding({ scanPermission: "scan_only" }); + expect(f.access()).toMatchObject({ scanPermission: "scan_only", autoScanKnownAgents: false, watchFileChanges: false }); + await f.executor.startScan({ sourceId: "all" }); + await vi.waitFor(() => expect(f.executor.scanStatus().running).toBe(false)); + expect(f.addMemory).toHaveBeenCalledOnce(); + await f.service.updateOnboarding({ scanPermission: "scan_only", currentStep: "scan_permission_required" }); + expect(f.access()).toMatchObject({ scanPermission: "scan_only", autoScanKnownAgents: true, watchFileChanges: true }); +}); diff --git a/App/backend/src/tests/index.test.ts b/App/backend/src/tests/index.test.ts index cb99cf0e6..8db7363cd 100644 --- a/App/backend/src/tests/index.test.ts +++ b/App/backend/src/tests/index.test.ts @@ -76,6 +76,46 @@ describe("local api", () => { } }); + it("recovers a refused permission after a failed save response and backend restart", async () => { + tempDir = mkdtempSync(join(tmpdir(), "memmy-backend-refusal-restart-")); + const databasePath = join(tempDir, "app.sqlite"); + const memmyConfigPath = join(tempDir, "config.yaml"); + const initial = createAppStateStore({ databasePath }); + initial.repositories.bootstrap.updateOnboarding({ scanPermission: "scan_only" }); + initial.close(); + const baseClient = createMockMemoryClient(); + const options = { + databasePath, memmyConfigPath, + runtimeConfigPath: join(tempDir, "runtime.json"), + localToken: "test-token", + cloudClient: createMockCloudClient(), + memoryClient: { + ...baseClient, + async reloadConfig(input: Parameters[0]) { + if (input?.reason === "scan_permission_saved") throw new Error("simulated Memory offline"); + return baseClient.reloadConfig(input); + } + } + }; + backend = await createLocalBackend(options); + const response = await fetch(`${backend.runtimeConfig.baseUrl}/api/app/onboarding`, { + method: "PATCH", headers: { "x-memmy-local-token": "test-token", "content-type": "application/json" }, + body: JSON.stringify({ scanPermission: "none", currentStep: "product_tour_required" }) + }); + expect(response.status).toBe(500); + expect(YAML.parse(readFileSync(memmyConfigPath, "utf8")).memmyMemory.agentAccess).toMatchObject({ + scanPermission: "none", autoScanKnownAgents: false, watchFileChanges: false + }); + await backend.close(); + backend = await createLocalBackend({ ...options, memoryClient: baseClient }); + const restored = createAppStateStore({ databasePath }); + try { + expect(restored.repositories.bootstrap.getOnboardingState()).toMatchObject({ + scanPermission: "none", currentStep: "scan_permission_required" + }); + } finally { restored.close(); } + }); + it("reloads Memory config when the desktop backend starts", async () => { tempDir = mkdtempSync(join(tmpdir(), "memmy-backend-startup-reload-")); const baseClient = createMockMemoryClient(); diff --git a/App/frontend/desktop/src/api/scan-preferences-writer.ts b/App/frontend/desktop/src/api/scan-preferences-writer.ts new file mode 100644 index 000000000..828942352 --- /dev/null +++ b/App/frontend/desktop/src/api/scan-preferences-writer.ts @@ -0,0 +1,63 @@ +import type { OnboardingStateDto, ScanPermission, ScanPreferences } from "@memmy/local-api-contracts"; +import type { ConfigClient } from "./config-client.js"; + +// Keep the queue with the client so navigating away/back cannot reorder writes. +const writers = new WeakMap>(); + +export function scanPreferencesWriter(client: ConfigClient) { + let writer = writers.get(client); + if (!writer) { + writer = createWriter(client); + writers.set(client, writer); + } + return writer; +} + +function createWriter(client: ConfigClient) { + let tail = Promise.resolve(); + let revision = 0; + let pending = 0; + let confirmed: ScanPreferences; + let permission: ScanPermission; + return { + get revision() { return revision; }, + canRefresh(startRevision: number) { return pending === 0 && startRevision === revision; }, + write(input: { + patch: Partial; + previous: ScanPreferences; + permission: ScanPermission; + onOnboarding: (value: Partial) => void; + onConfirmed: (value: ScanPreferences) => void; + onError: (error: unknown) => void; + }): Promise { + if (pending === 0) { + confirmed = input.previous; + permission = input.permission; + } + pending += 1; + const ownRevision = ++revision; + tail = tail.then(async () => { + try { + const grant = input.patch.autoInjectSkill === true ? "scan_and_write_skill" + : (input.patch.autoScanKnownAgents === true || input.patch.watchFileChanges === true) + && permission !== "scan_only" && permission !== "scan_and_write_skill" ? "scan_only" : undefined; + if (grant) { + const onboarding = await client.updateOnboarding({ scanPermission: grant }); + permission = onboarding.scanPermission ?? grant; + input.onOnboarding(onboarding); + } + confirmed = await client.updateScanPreferences(input.patch); + if (ownRevision === revision) input.onConfirmed(confirmed); + } catch (error) { + if (ownRevision === revision) { + input.onConfirmed(confirmed); + input.onError(error); + } + } finally { + pending -= 1; + } + }); + return tail; + } + }; +} diff --git a/App/frontend/desktop/src/api/tests/scan-preferences-writer.test.ts b/App/frontend/desktop/src/api/tests/scan-preferences-writer.test.ts new file mode 100644 index 000000000..372af3c40 --- /dev/null +++ b/App/frontend/desktop/src/api/tests/scan-preferences-writer.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OnboardingStateDto, ScanPreferences } from "@memmy/local-api-contracts"; +import type { ConfigClient } from "../config-client.js"; +import { scanPreferencesWriter } from "../scan-preferences-writer.js"; + +function deferred() { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +function fixture() { + let stored: ScanPreferences = { autoScanKnownAgents: false, watchFileChanges: false, autoInjectSkill: false }; + const grant = deferred(); + const client = { + updateOnboarding: vi.fn(async ({ scanPermission }) => { + await grant.promise; + return { scanPermission } as OnboardingStateDto; + }), + updateScanPreferences: vi.fn(async (patch) => { stored = { ...stored, ...patch }; return stored; }) + } as unknown as ConfigClient; + const writer = scanPreferencesWriter(client); + const confirmed = vi.fn(); + const error = vi.fn(); + const write = (patch: Partial) => writer.write({ + patch, previous: stored, permission: "none", onOnboarding: vi.fn(), onConfirmed: confirmed, onError: error + }); + return { client, writer, grant, write, confirmed, error, stored: () => stored }; +} +describe("scan preference intent ordering", () => { + it.each([[true, false], [true, false, true]])("serializes whole grant/save operations: %j", async (...choices) => { + const f = fixture(); + const before = f.writer.revision; + const writes = choices.map(value => f.write({ autoScanKnownAgents: value })); + await Promise.resolve(); + expect(f.client.updateScanPreferences).not.toHaveBeenCalled(); + expect(f.writer.canRefresh(before)).toBe(false); + f.grant.resolve(); + await Promise.all(writes); + const last = choices.at(-1); + expect(f.stored().autoScanKnownAgents).toBe(last); + expect(f.confirmed).toHaveBeenCalledTimes(1); + expect(f.confirmed).toHaveBeenCalledWith(expect.objectContaining({ autoScanKnownAgents: last })); + expect(f.writer.canRefresh(before)).toBe(false); + expect(f.writer.canRefresh(f.writer.revision)).toBe(true); + }); + it("does not let a late grant failure roll back a newer disable, and permits retry", async () => { + const f = fixture(); + const enable = f.write({ autoScanKnownAgents: true }); + const disable = f.write({ autoScanKnownAgents: false }); + f.grant.reject(new Error("save failed")); + await Promise.all([enable, disable]); + expect(f.stored().autoScanKnownAgents).toBe(false); + expect(f.error).not.toHaveBeenCalled(); + expect(f.confirmed).toHaveBeenCalledTimes(1); + vi.mocked(f.client.updateOnboarding).mockResolvedValue({ scanPermission: "scan_only" } as OnboardingStateDto); + await f.write({ autoScanKnownAgents: true }); + expect(f.stored().autoScanKnownAgents).toBe(true); + }); + it("rolls back a failed latest write to the last confirmed result, keeping unrelated switches", async () => { + const f = fixture(); + vi.mocked(f.client.updateScanPreferences).mockImplementationOnce(async patch => ({ ...f.stored(), ...patch })) + .mockRejectedValueOnce(new Error("disk full")); + const first = f.write({ watchFileChanges: true }); + const second = f.write({ autoScanKnownAgents: true }); + f.grant.resolve(); + await Promise.all([first, second]); + expect(f.confirmed).toHaveBeenLastCalledWith({ autoScanKnownAgents: false, watchFileChanges: true, autoInjectSkill: false }); + expect(f.error).toHaveBeenCalledTimes(1); + }); + it("shares the queue across page remounts and does not downgrade an earlier skill grant", async () => { + const f = fixture(); + const first = f.write({ autoInjectSkill: true }); + expect(scanPreferencesWriter(f.client)).toBe(f.writer); + const second = f.write({ autoScanKnownAgents: true }); + f.grant.resolve(); + await Promise.all([first, second]); + expect(f.client.updateOnboarding).toHaveBeenCalledTimes(1); + expect(f.client.updateOnboarding).toHaveBeenCalledWith({ scanPermission: "scan_and_write_skill" }); + expect(f.stored()).toEqual({ autoScanKnownAgents: true, watchFileChanges: false, autoInjectSkill: true }); + }); +}); diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index 38b882c57..4910c18a7 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -461,6 +461,7 @@ export const zhCNMessages = { "onboarding.permission.writeTitle": "让常用 Agent 自动接入 Memmy 记忆", "onboarding.permission.writeBody": "继续使用原来的 Agent,切换工具或新开对话时,自动同步与注入相关背景", "onboarding.permission.notice": "你可以随时在「记忆管理 -> 跨 Agent 接入」中调整授权", + "onboarding.permission.saveFailed": "保存扫描设置失败,请重试。", "onboarding.permission.none": "不允许", "onboarding.permission.scan": "仅允许扫描", "onboarding.permission.all": "全部允许", @@ -2702,6 +2703,7 @@ export const enUSMessages: Record = { "onboarding.permission.writeTitle": "Let your usual Agents connect to Memmy memory", "onboarding.permission.writeBody": "Keep using your Agents; when you switch tools or start a new chat, relevant context is synced and injected automatically", "onboarding.permission.notice": "You can change this later in Memory -> Cross-Agent access", + "onboarding.permission.saveFailed": "Could not save scanning settings. Please try again.", "onboarding.permission.none": "Deny", "onboarding.permission.scan": "Scan only", "onboarding.permission.all": "Allow all", diff --git a/App/frontend/desktop/src/pages/memory-sources-page.tsx b/App/frontend/desktop/src/pages/memory-sources-page.tsx index 92f825a36..77b099942 100644 --- a/App/frontend/desktop/src/pages/memory-sources-page.tsx +++ b/App/frontend/desktop/src/pages/memory-sources-page.tsx @@ -7,6 +7,7 @@ import { type HealthStatus, type ScanPreferences } from "@memmy/local-api-contracts"; +import { scanPreferencesWriter } from "../api/scan-preferences-writer.js"; import { ApiRequestError } from "../api/http.js"; import type { AppClients } from "../api/client-types.js"; import { @@ -308,10 +309,13 @@ export function MemorySourcesContent(props: MemorySourcesContentProps = {}) { useEffect(() => { if (!clients) return; let active = true; + const writer = scanPreferencesWriter(clients.config); const refresh = () => { + const revision = writer.revision; + if (!writer.canRefresh(revision)) return; void clients.config.getScanPreferences() .then((preferences) => { - if (active) dispatch(appActions.scanPreferencesUpdated(preferences)); + if (active && writer.canRefresh(revision)) dispatch(appActions.scanPreferencesUpdated(preferences)); }) .catch(() => undefined); }; @@ -507,13 +511,14 @@ export function MemorySourcesContent(props: MemorySourcesContentProps = {}) { return; } - void clients.config - .updateScanPreferences(preferences) - .then((persisted) => dispatch(appActions.scanPreferencesUpdated(persisted))) - .catch((error) => { - dispatch(appActions.scanPreferencesUpdated(previous)); - dispatch(appActions.agentSourcesFailed(error instanceof Error ? error.message : String(error))); - }); + void scanPreferencesWriter(clients.config).write({ + patch: preferences, + previous, + permission: state.bootstrap?.onboarding.scanPermission ?? "unset", + onOnboarding: (onboarding) => dispatch(appActions.onboardingUpdated(onboarding)), + onConfirmed: (persisted) => dispatch(appActions.scanPreferencesUpdated(persisted)), + onError: (error) => dispatch(appActions.agentSourcesFailed(error instanceof Error ? error.message : String(error))) + }); } function continueScan() { diff --git a/App/frontend/desktop/src/pages/onboarding-page.tsx b/App/frontend/desktop/src/pages/onboarding-page.tsx index 81178d537..9a4aba49b 100644 --- a/App/frontend/desktop/src/pages/onboarding-page.tsx +++ b/App/frontend/desktop/src/pages/onboarding-page.tsx @@ -66,6 +66,9 @@ export function OnboardingPage() { const { clients } = useApiClients(); const { track } = useAnalytics(); const { t, language } = useTranslation(); + const permissionSavingRef = useRef(false); + const [permissionSaving, setPermissionSaving] = useState(false); + const [permissionError, setPermissionError] = useState(null); const [firstScanStep, setFirstScanStep] = useState(null); const [firstScanAgents, setFirstScanAgents] = useState(null); const [firstReportPayload, setFirstReportPayload] = useState(null); @@ -237,8 +240,22 @@ export function OnboardingPage() { } as const : { completed: false, currentStep: "scan_permission_required", scanPermission: permission } as const; - dispatch(appActions.onboardingUpdated(patch)); - dispatch(appActions.scanPreferencesUpdated(preferences)); + if (permissionSavingRef.current || !clients) return; + permissionSavingRef.current = true; + setPermissionSaving(true); + setPermissionError(null); + try { + const persistedPatch = await clients.config.updateOnboarding(patch); + dispatch(appActions.scanPreferencesUpdated(preferences)); + dispatch(appActions.onboardingUpdated(persistedPatch)); + } catch (error) { + console.warn("save scan permission failed", error); + setPermissionError(t("onboarding.permission.saveFailed")); + return; + } finally { + permissionSavingRef.current = false; + setPermissionSaving(false); + } track(buildOnboardingStepCompletedEvent({ step: "scan_permission", choice: permission, @@ -248,9 +265,6 @@ export function OnboardingPage() { prepareFirstScanUi(permission === "scan_and_write_skill" ? "checking_plugins" : "scanning"); if (clients) { try { - const persistedPatch = await clients.config.updateOnboarding(patch); - dispatch(appActions.onboardingUpdated(persistedPatch)); - dispatch(appActions.scanPreferencesUpdated(await clients.config.updateScanPreferences(preferences))); if (permission === "scan_and_write_skill") { void startFirstScanInBackground().catch((error) => { console.warn("start first agent source scan failed", error); @@ -268,19 +282,6 @@ export function OnboardingPage() { console.warn("start first agent source scan failed", error); } } - } else { - void clients?.config - .updateOnboarding(patch) - .then((persistedPatch) => { - dispatch(appActions.onboardingUpdated(persistedPatch)); - return clients.config.updateScanPreferences(preferences); - }) - .then((persistedPreferences) => { - dispatch(appActions.scanPreferencesUpdated(persistedPreferences)); - }) - .catch((error) => { - console.warn("save scan permission failed", error); - }); } } @@ -350,9 +351,19 @@ export function OnboardingPage() { await startFirstScanWithAnimation(); } - function returnToScanPermission() { + async function returnToScanPermission() { const onboardingPatch = { completed: false, currentStep: "scan_permission_required", scanPermission: "unset" } as const; - const preferences = { autoScanKnownAgents: true, watchFileChanges: true, autoInjectSkill: false }; + const preferences = { autoScanKnownAgents: false, watchFileChanges: false, autoInjectSkill: false }; + if (!clients || permissionSavingRef.current) return; + permissionSavingRef.current = true; + try { + await clients.config.updateOnboarding(onboardingPatch); + } catch (error) { + dispatch(appActions.agentSourcesFailed(error instanceof Error ? error.message : String(error))); + return; + } finally { + permissionSavingRef.current = false; + } setPluginConflictOpen(false); setPluginConflicts([]); @@ -369,18 +380,6 @@ export function OnboardingPage() { setFirstScanStep(null); dispatch(appActions.onboardingUpdated(onboardingPatch)); dispatch(appActions.scanPreferencesUpdated(preferences)); - void clients?.config - .updateOnboarding(onboardingPatch) - .then((persistedPatch) => dispatch(appActions.onboardingUpdated(persistedPatch))) - .catch((error) => { - console.warn("return to scan permission failed", error); - }); - void clients?.config - .updateScanPreferences(preferences) - .then((persistedPreferences) => dispatch(appActions.scanPreferencesUpdated(persistedPreferences))) - .catch((error) => { - console.warn("reset scan preferences failed", error); - }); } async function finishMemoryPluginConflictInstall(replace: boolean, conflicts: AgentSourceMemoryPluginConflict[]) { @@ -843,9 +842,11 @@ export function OnboardingPage() {

{t("onboarding.permission.notice")}

+ {permissionError &&

{permissionError}

}