From 9669fcce8e74121d834a31720029e911c49827a7 Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Mon, 7 Sep 2026 03:30:17 +0800 Subject: [PATCH 1/2] fix(desktop): offer to restore an archived project when re-adding its directory Adding a directory that belongs to an archived project previously just failed with no way forward. The add flow now returns an `archived` reason with the project id, and the app shell, task-entry workspace picker, and projects settings page offer a confirm dialog to restore the archived project in place. Also adds a `restoreProject` bridge method and error logging to the runtime-host project/skill catalog coordinators so commit_outcome_unknown failures are visible in logs. --- .../app-shell-project-actions.test.ts | 3 +- .../project-management-service.test.ts | 33 +++++++ .../__tests__/task-entry-controller.test.ts | 92 ++++++++++++++++++- .../__tests__/use-project-context.test.ts | 2 +- .../src/main/project-management-service.ts | 6 +- apps/desktop/src/preload/bridge-contract.d.ts | 9 +- apps/desktop/src/preload/preload.ts | 11 ++- .../src/renderer/app-shell-project-actions.ts | 19 ++++ .../controller/use-task-entry-controller.ts | 45 ++++++--- .../src/renderer/features/task-entry/ports.ts | 4 +- .../renderer/features/task-entry/testing.ts | 1 + .../locales/settings-projects-copy.ts | 16 ++++ .../src/renderer/locales/shell-copy.ts | 16 ++++ .../settings/projects-settings-page.tsx | 12 +++ .../src/renderer/use-project-context.ts | 7 ++ .../src/server/project-catalog-coordinator.ts | 5 + .../src/server/skill-catalog-coordinator.ts | 2 + 17 files changed, 262 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts index cc9fe153ad..13b9a7a902 100644 --- a/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts @@ -49,7 +49,7 @@ function createTestProjectActions( projects: [], projectCapabilities: NO_PROJECT_CAPABILITIES, onProjectSelected: () => {}, - toastApi: { success: () => {}, error: () => {} }, + toastApi: { success: () => {}, error: () => {}, confirm: async () => true }, ...overrides, }); } @@ -98,6 +98,7 @@ test('Project errors preserve the Host authority of the failed operation', async error: (_title: string, _description?: string, _details?: string, target?: unknown) => { diagnosticTargets.push(target); }, + confirm: async () => false, }; globalThis.window = { maka: { diff --git a/apps/desktop/src/main/__tests__/project-management-service.test.ts b/apps/desktop/src/main/__tests__/project-management-service.test.ts index fef7a9cb70..affa113a6b 100644 --- a/apps/desktop/src/main/__tests__/project-management-service.test.ts +++ b/apps/desktop/src/main/__tests__/project-management-service.test.ts @@ -135,6 +135,39 @@ test('adding a nested folder selects that folder instead of the parent project', } }); +test('re-adding an archived project reports the archived project instead of failing', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-archived-add-')); + const projectPath = join(base, 'archived-project'); + await mkdir(projectPath); + const selectedPaths: string[] = []; + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => 1_000, + createId: () => 'project-1', + }); + const service = createProjectManagementService({ + capabilities: LOCAL_CAPABILITIES, + catalog: managementCatalog(catalog), + chooseDirectory: async () => projectPath, + selection: { + currentSelection: async () => ({ projectId: undefined, path: base }), + setSelection: (_projectId, path) => selectedPaths.push(path), + }, + }); + + try { + const first = await service.add(); + assert.equal(first.ok, true); + await service.archive('project-1'); + + const second = await service.add(); + assert.deepEqual(second, { ok: false, reason: 'archived', projectId: 'project-1' }); + assert.equal(selectedPaths.length, 1); + } finally { + catalog.close(); + await rm(base, { recursive: true, force: true }); + } +}); + test('can register a draft Project without changing the Host selection', async () => { let selected = false; const service = createProjectManagementService({ diff --git a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts index 393ca2f297..4afdb6ae25 100644 --- a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts @@ -21,7 +21,7 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; import { act, createElement } from 'react'; -import { LocaleProvider } from '@maka/ui'; +import { LocaleProvider, ToastProvider } from '@maka/ui'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { createFakeTaskEntryServices, @@ -97,10 +97,14 @@ function catalog(host: TaskEntryHost = readyHost()): TaskEntryCatalog { } let latestController: TaskEntryController | undefined; -function ControllerProbe(props: { reportError(error: unknown): void }) { +function ControllerProbe(props: { + reportError(error: unknown): void; + confirm?(input: { title: string }): Promise; +}) { latestController = useTaskEntryController({ reportError: props.reportError, manageProjects() {}, + ...(props.confirm ? { confirm: props.confirm } : {}), }); return null; } @@ -114,17 +118,23 @@ function renderController( root: ReturnType['root'], services: TaskEntryServices, errors: unknown[] = [], + confirm?: (input: { title: string }) => Promise, ) { root.render( createElement(LocaleProvider, { locale: 'en', children: createElement( + ToastProvider, + null, + createElement( TaskEntryServicesProvider, { services }, createElement(ControllerProbe, { reportError: (error: unknown) => errors.push(error), + confirm, }), ), + ), }), ); } @@ -272,6 +282,84 @@ describe('useTaskEntryController', () => { assert.equal(controller().selectors.workspacePicker.pending, false); }); + it('prompts to restore an archived Project and selects it after confirmation', async () => { + const { root } = installReactRenderer(); + let reads = 0; + let restoreCalls = 0; + const refreshedHost = readyHost({ + projects: [project('project-a'), project('project-b')], + selectedProjectId: 'project-a', + }); + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => + catalog(++reads === 1 ? readyHost() : refreshedHost), + addProject: async () => ({ + ok: false as const, + reason: 'archived' as const, + projectId: 'project-a', + }), + restoreProject: async () => { + restoreCalls += 1; + return { ok: true as const, project: project('project-a') }; + }, + }, + }); + + await act(async () => renderController(root, services, [], async () => true)); + await act(async () => { + controller().commands.addProject(); + await Promise.resolve(); + }); + await act(async () => {}); + + assert.equal(restoreCalls, 1); + assert.equal(controller().selectors.target?.projectId, 'project-a'); + }); + + it('reports restore failure and releases the pending state so the user can retry', async () => { + const { root } = installReactRenderer(); + const restoration = deferred<{ ok: true; project: ReturnType }>(); + const errors: unknown[] = []; + let restoreCalls = 0; + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => catalog(), + addProject: async () => ({ + ok: false, + reason: 'archived', + projectId: 'project-b', + }), + restoreProject: async () => { + restoreCalls += 1; + return restoreCalls === 1 + ? restoration.promise + : { ok: false, reason: 'cancelled' }; + }, + }, + }); + + await act(async () => renderController(root, services, errors, async () => true)); + await act(async () => controller().commands.addProject()); + assert.equal(controller().selectors.workspacePicker.pending, true); + + await act(async () => restoration.reject(new Error('restore failed'))); + + assert.deepEqual(errors, [{ + title: 'Could not select working directory', + description: 'The project path is temporarily unavailable. Try again later.', + profileId: 'local', + }]); + assert.equal(controller().selectors.target?.projectId, 'project-a'); + assert.equal(controller().selectors.workspacePicker.pending, false); + + await act(async () => controller().commands.addProject()); + assert.equal(restoreCalls, 2); + assert.equal(errors.length, 1); + }); + it('deduplicates relink requests and selects the returned Project before refreshing', async () => { const { root } = installReactRenderer(); const relinked = deferred<{ diff --git a/apps/desktop/src/main/__tests__/use-project-context.test.ts b/apps/desktop/src/main/__tests__/use-project-context.test.ts index b08e0d6dec..01f0ef5611 100644 --- a/apps/desktop/src/main/__tests__/use-project-context.test.ts +++ b/apps/desktop/src/main/__tests__/use-project-context.test.ts @@ -85,7 +85,7 @@ test('discards a pending Project projection after the default Host changes', asy uiLocale: 'en', rendererMountedRef: { current: true }, onProjectSelected: () => {}, - toastApi: { success: () => {}, error: () => {} }, + toastApi: { success: () => {}, error: () => {}, confirm: async () => true }, }); projects = context.projects; selectedProjectId = context.selectedProjectId; diff --git a/apps/desktop/src/main/project-management-service.ts b/apps/desktop/src/main/project-management-service.ts index 9b5effd22f..f1731403cc 100644 --- a/apps/desktop/src/main/project-management-service.ts +++ b/apps/desktop/src/main/project-management-service.ts @@ -31,7 +31,8 @@ type DirectoryActionResult = | { ok: false; reason: 'cancelled' }; type SelectedDirectoryActionResult = | { ok: true; project: ProjectRecord; path: string } - | { ok: false; reason: 'cancelled' }; + | { ok: false; reason: 'cancelled' } + | { ok: false; reason: 'archived'; projectId: string }; export interface ProjectManagementService { current(): Promise; @@ -122,6 +123,9 @@ export function createProjectManagementService(deps: { const path = await deps.chooseDirectory(); if (!path) return { ok: false, reason: 'cancelled' }; const project = await deps.catalog.register(path); + if (project.archivedAt !== undefined) { + return { ok: false, reason: 'archived', projectId: project.id }; + } const selected = requireSelectableProject(project); if (options?.select !== false) { deps.selection.setSelection(selected.id, selected.preferredPath); diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 9983cd51b8..19e98b069b 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -889,6 +889,11 @@ export interface MakaBridge { getCatalog(): Promise; subscribeChanges(handler: () => void): () => void; addProject(host: DesktopNewTaskHostRef): Promise< + | { ok: true; project: ProjectRecord } + | { ok: false; reason: 'cancelled' } + | { ok: false; reason: 'archived'; projectId: string } + >; + restoreProject(host: DesktopNewTaskHostRef, projectId: string): Promise< { ok: true; project: ProjectRecord } | { ok: false; reason: 'cancelled' } >; relinkProject(host: DesktopNewTaskHostRef, projectId: string): Promise< @@ -1288,7 +1293,9 @@ export interface MakaBridge { getLocalSnapshot(): Promise; subscribeLocalChanges(handler: () => void): () => void; add(host?: DesktopRuntimeHostRef): Promise< - { ok: true; project: ProjectRecord; path: string } | { ok: false; reason: 'cancelled' } + | { ok: true; project: ProjectRecord; path: string } + | { ok: false; reason: 'cancelled' } + | { ok: false; reason: 'archived'; projectId: string } >; getDirectoryRoots(host: DesktopRuntimeHostRef): Promise; listDirectory( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 974ffa10fa..7c8d8874ff 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1800,9 +1800,18 @@ const makaBridge = { { select: false }, ) as | { ok: true; project: ProjectRecord; path: string } - | { ok: false; reason: 'cancelled' }; + | { ok: false; reason: 'cancelled' } + | { ok: false; reason: 'archived'; projectId: string }; return result.ok ? { ok: true as const, project: result.project } : result; }, + async restoreProject(host: DesktopNewTaskHostRef, projectId: string) { + const project = await ipcRenderer.invoke( + 'projects:restore', + await runtimeHostScope(host), + projectId, + ) as ProjectRecord; + return { ok: true as const, project }; + }, async relinkProject(host: DesktopNewTaskHostRef, projectId: string) { return ipcRenderer.invoke( 'projects:relink', diff --git a/apps/desktop/src/renderer/app-shell-project-actions.ts b/apps/desktop/src/renderer/app-shell-project-actions.ts index 304e635416..f449c04684 100644 --- a/apps/desktop/src/renderer/app-shell-project-actions.ts +++ b/apps/desktop/src/renderer/app-shell-project-actions.ts @@ -53,6 +53,13 @@ type ToastApi = { diagnosticDetails?: string, diagnosticTarget?: { sessionId: string } | { profileId: string }, ): void; + confirm(input: { + title: string; + description?: string; + confirmLabel?: string; + cancelLabel?: string; + destructive?: boolean; + }): Promise; }; export interface AppShellProjectActions { @@ -160,6 +167,18 @@ export function createAppShellProjectActions(deps: { try { const result = await runOnDefaultRuntimeHost(async (host) => { const added = await window.maka.projects.add(host); + if (!added.ok && added.reason === 'archived') { + const confirmed = await toastApi.confirm({ + title: copy.archivedProjectTitle, + description: copy.archivedProjectDescription, + confirmLabel: copy.archivedProjectRestore, + cancelLabel: copy.archivedProjectCancel, + }); + if (!confirmed) return added; + const restored = await window.maka.projects.restore(added.projectId, host); + await applySelectedProject(restored, restored.preferredPath ?? '', true, host); + return { ok: true as const, project: restored, path: restored.preferredPath ?? '' }; + } if (!added.ok) return added; await applySelectedProject(added.project, added.path, true, host); return added; diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts index 4a3bd62b52..f1e95abc8e 100644 --- a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts +++ b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts @@ -32,6 +32,7 @@ import { import { getConversationCopy, type WorkspacePickerModel, + useToast, useUiLocale, } from '@maka/ui'; import { @@ -58,6 +59,13 @@ import type { TaskEntryHostModel } from '../ui/task-entry-host.js'; export interface UseTaskEntryControllerInput { reportError(error: TaskEntryError): void; manageProjects(profileId: string): void; + /** Defaults to the app toast confirm dialog; injected in tests. */ + confirm?(input: { + title: string; + description?: string; + confirmLabel?: string; + cancelLabel?: string; + }): Promise; } export interface TaskEntryControllerSelectors { @@ -130,6 +138,7 @@ export function useTaskEntryController( input: UseTaskEntryControllerInput, ): TaskEntryController { const locale = useUiLocale(); + const toast = useToast(); const copy = getShellCopy(locale).projectActions; const conversationCopy = getConversationCopy(locale).workspace; const reportError = input.reportError; @@ -289,19 +298,23 @@ export function useTaskEntryController( projectMutationPendingRef.current = true; setPending(true); try { - let result: TaskEntryProjectMutationResult; - try { - result = await service.addProject({ - profileId: host.profile.id, - hostId: host.hostId, - }); - } catch (cause) { - reportError({ - title: copy.selectDirectoryFailedTitle, - description: localizedShellErrorMessage(cause, copy.readPathFailedFallback, locale), - profileId: host.profile.id, + let result: TaskEntryProjectMutationResult = await service.addProject({ + profileId: host.profile.id, + hostId: host.hostId, + }); + if (!result.ok && result.reason === 'archived') { + const confirmed = await (input.confirm ?? toast.confirm)({ + title: copy.archivedProjectTitle, + description: copy.archivedProjectDescription, + confirmLabel: copy.archivedProjectRestore, + cancelLabel: copy.archivedProjectCancel, }); - return; + if (!confirmed) return; + result = await service.restoreProject( + { profileId: host.profile.id, hostId: host.hostId }, + result.projectId, + ); + if (!result.ok) return; } if (!result.ok) return; setSelectedProfileId(host.profile.id); @@ -309,11 +322,17 @@ export function useTaskEntryController( new Map(current).set(host.profile.id, result.project.id), ); await refreshAfterProjectMutation(host.profile.id); + } catch (cause) { + reportError({ + title: copy.selectDirectoryFailedTitle, + description: localizedShellErrorMessage(cause, copy.readPathFailedFallback, locale), + profileId: host.profile.id, + }); } finally { projectMutationPendingRef.current = false; setPending(false); } - }, [copy.readPathFailedFallback, copy.selectDirectoryFailedTitle, locale, refreshAfterProjectMutation, reportError, service]); + }, [copy.archivedProjectCancel, copy.archivedProjectDescription, copy.archivedProjectRestore, copy.archivedProjectTitle, copy.readPathFailedFallback, copy.selectDirectoryFailedTitle, locale, refreshAfterProjectMutation, reportError, service, toast]); const chooseProjectForProfile = useCallback(async (profileId: string): Promise => { let next: TaskEntryCatalog | undefined; diff --git a/apps/desktop/src/renderer/features/task-entry/ports.ts b/apps/desktop/src/renderer/features/task-entry/ports.ts index f1514fc19e..dca9296470 100644 --- a/apps/desktop/src/renderer/features/task-entry/ports.ts +++ b/apps/desktop/src/renderer/features/task-entry/ports.ts @@ -87,13 +87,15 @@ export interface TaskEntryCatalog { export type TaskEntryProjectMutationResult = | { readonly ok: true; readonly project: ProjectRecord } - | { readonly ok: false; readonly reason: 'cancelled' }; + | { readonly ok: false; readonly reason: 'cancelled' } + | { readonly ok: false; readonly reason: 'archived'; readonly projectId: string }; /** The minimum environment capability needed by Task Entry / Workspace. */ export interface TaskEntryCatalogService { getCatalog(): Promise; subscribeChanges(handler: () => void): TaskEntryUnsubscribe; addProject(host: TaskEntryHostRef): Promise; + restoreProject(host: TaskEntryHostRef, projectId: string): Promise; relinkProject( host: TaskEntryHostRef, projectId: string, diff --git a/apps/desktop/src/renderer/features/task-entry/testing.ts b/apps/desktop/src/renderer/features/task-entry/testing.ts index 9d9ac6f472..9cf4c7272d 100644 --- a/apps/desktop/src/renderer/features/task-entry/testing.ts +++ b/apps/desktop/src/renderer/features/task-entry/testing.ts @@ -51,6 +51,7 @@ export function createFakeTaskEntryServices( getCatalog: async () => ({ defaultProfileId: 'local', hosts: [] }), subscribeChanges: noopSubscription, addProject: async () => ({ ok: false, reason: 'cancelled' }), + restoreProject: async () => ({ ok: false, reason: 'cancelled' }), relinkProject: async () => ({ ok: false, reason: 'cancelled' }), }, ...overrides, diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts index 78ee694394..3df6e7fb2b 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -283,6 +283,10 @@ export type SettingsProjectsCopy = { section: string; sectionHelp: string; addProject: string; + archivedProjectTitle: string; + archivedProjectDescription: string; + archivedProjectRestore: string; + archivedProjectCancel: string; defaultBadge: string; setDefault: string; setDefaultTitle: string; @@ -641,6 +645,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { // happens before they set one. sectionHelp: '新任务默认打开此项目;未设置时沿用上次使用的项目。任何任务都能在输入框旁临时切换。', addProject: '添加项目', + archivedProjectTitle: '项目已归档', + archivedProjectDescription: '该项目已归档,是否需要恢复?', + archivedProjectRestore: '恢复', + archivedProjectCancel: '取消', defaultBadge: '默认', setDefault: '设为默认', setDefaultTitle: '新任务默认打开这个项目', @@ -979,6 +987,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { // happens before they set one. sectionHelp: '新任務預設開啟此專案;未設定時沿用上次使用的專案。任何任務都能在輸入框旁臨時切換。', addProject: '新增專案', + archivedProjectTitle: '專案已歸檔', + archivedProjectDescription: '該專案已歸檔,是否需要恢復?', + archivedProjectRestore: '恢復', + archivedProjectCancel: '取消', defaultBadge: '預設', setDefault: '設為預設', setDefaultTitle: '新任務預設開啟這個專案', @@ -1335,6 +1347,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { sectionHelp: 'New tasks open in the default project; without one, they reuse the project you last used. You can switch any task to a different project next to the input box.', addProject: 'Add project', + archivedProjectTitle: 'Project archived', + archivedProjectDescription: 'This project is archived. Restore it?', + archivedProjectRestore: 'Restore', + archivedProjectCancel: 'Cancel', defaultBadge: 'Default', setDefault: 'Set as default', setDefaultTitle: 'Open new tasks in this project', diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 80407441a1..83d6ee266e 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -194,6 +194,10 @@ type ShellCopy = { projectUpdateFailedFallback: string; catalogUnavailable: string; retryCatalog: string; + archivedProjectTitle: string; + archivedProjectDescription: string; + archivedProjectRestore: string; + archivedProjectCancel: string; remoteDirectoryTitle(host: string): string; remoteDirectoryBreadcrumbs: string; remoteDirectoryHome: string; @@ -797,6 +801,10 @@ const SHELL_COPY_BY_LOCALE = { projectUpdateFailedFallback: '暂时无法更新项目,请稍后重试。', catalogUnavailable: 'Runtime Host 暂时不可用', retryCatalog: '重试加载', + archivedProjectTitle: '项目已归档', + archivedProjectDescription: '该项目已归档,是否需要恢复?', + archivedProjectRestore: '恢复', + archivedProjectCancel: '取消', remoteDirectoryTitle: (host: string) => `在 ${host} 上添加项目`, remoteDirectoryBreadcrumbs: '当前文件夹', remoteDirectoryHome: '主目录', @@ -1290,6 +1298,10 @@ const SHELL_COPY_BY_LOCALE = { projectUpdateFailedFallback: '暫時無法更新專案,請稍後重試。', catalogUnavailable: 'Runtime Host 暫時不可用', retryCatalog: '重試載入', + archivedProjectTitle: '專案已歸檔', + archivedProjectDescription: '該專案已歸檔,是否需要恢復?', + archivedProjectRestore: '恢復', + archivedProjectCancel: '取消', remoteDirectoryTitle: (host: string) => `在 ${host} 上新增專案`, remoteDirectoryBreadcrumbs: '目前資料夾', remoteDirectoryHome: '主目錄', @@ -1785,6 +1797,10 @@ const SHELL_COPY_BY_LOCALE = { projectUpdateFailedFallback: 'The project could not be updated. Try again later.', catalogUnavailable: 'Runtime Hosts unavailable', retryCatalog: 'Retry loading', + archivedProjectTitle: 'Project archived', + archivedProjectDescription: 'This project is archived. Restore it?', + archivedProjectRestore: 'Restore', + archivedProjectCancel: 'Cancel', remoteDirectoryTitle: (host: string) => `Add a project on ${host}`, remoteDirectoryBreadcrumbs: 'Current folder', remoteDirectoryHome: 'Home', diff --git a/apps/desktop/src/renderer/settings/projects-settings-page.tsx b/apps/desktop/src/renderer/settings/projects-settings-page.tsx index 4b94af4c79..4e58ba0aa9 100644 --- a/apps/desktop/src/renderer/settings/projects-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/projects-settings-page.tsx @@ -272,6 +272,18 @@ export function ProjectsSettingsPage(props: { : async () => { if (!props.runtimeHostTargetVerified) return; const result = await window.maka.projects.add(host); + if (!result.ok && result.reason === 'archived') { + const ok = await toast.confirm({ + title: copy.archivedProjectTitle, + description: copy.archivedProjectDescription, + confirmLabel: copy.archivedProjectRestore, + cancelLabel: copy.archivedProjectCancel, + }); + if (!ok) return; + await window.maka.projects.restore(result.projectId, host); + await reload(); + return; + } if (result.ok) await reload(); }} /> diff --git a/apps/desktop/src/renderer/use-project-context.ts b/apps/desktop/src/renderer/use-project-context.ts index 8b67598220..adca371cc9 100644 --- a/apps/desktop/src/renderer/use-project-context.ts +++ b/apps/desktop/src/renderer/use-project-context.ts @@ -42,6 +42,13 @@ type RefBox = { current: T }; type ToastApi = { success(title: string, description?: string): void; error(title: string, description?: string): void; + confirm(input: { + title: string; + description?: string; + confirmLabel?: string; + cancelLabel?: string; + destructive?: boolean; + }): Promise; }; const NO_PROJECT_CAPABILITIES: DesktopProjectCapabilities = { diff --git a/packages/runtime-host/src/server/project-catalog-coordinator.ts b/packages/runtime-host/src/server/project-catalog-coordinator.ts index 7c3040560f..7f17e838d2 100644 --- a/packages/runtime-host/src/server/project-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/project-catalog-coordinator.ts @@ -18,6 +18,7 @@ */ import { createHash } from 'node:crypto'; +import { generalizedErrorMessage } from '@maka/core/redaction'; import type { ProjectRecord } from '@maka/core/project'; import { ProjectArchivedError, @@ -143,6 +144,10 @@ export class HostProjectCatalogCoordinator { if (error instanceof TypeError || isInvalidPathError(error)) { return mutationFailure('invalid_request', 'Project catalog input is invalid'); } + console.error( + `[runtime-host] project catalog mutation ${input.kind} failed: ${generalizedErrorMessage(error)}`, + error, + ); this.requestDrain(); return mutationFailure( 'commit_outcome_unknown', diff --git a/packages/runtime-host/src/server/skill-catalog-coordinator.ts b/packages/runtime-host/src/server/skill-catalog-coordinator.ts index 39e53bbcf0..889a9deb5e 100644 --- a/packages/runtime-host/src/server/skill-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/skill-catalog-coordinator.ts @@ -26,6 +26,7 @@ import type { WorkspaceProjection, } from '../protocol/index.js'; import type { ConnectionContext, SkillCatalogOperationHandlerMap } from './operation-dispatcher.js'; +import { generalizedErrorMessage } from '@maka/core/redaction'; import type { HostCapabilities } from '@maka/runtime/skills'; import { SkillCatalogRepository, @@ -266,6 +267,7 @@ function repositoryFailure( error: { code: 'invalid_request', message: error.message }, } as OperationOutcome; } + console.error(`[runtime-host] ${operation} failed: ${generalizedErrorMessage(error)}`, error); return { ok: false, error: { code: 'internal_failure', message: 'Skill catalog operation failed' }, From a1faaa0d859e70cae67c9d2dd1c602f7befe435f Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Tue, 8 Sep 2026 13:19:04 +0800 Subject: [PATCH 2/2] chore: replace applySelectedProject with selectProjectRecord --- .../app-shell-project-actions.test.ts | 86 +++++++++++++++++++ .../src/renderer/app-shell-project-actions.ts | 2 +- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts index 13b9a7a902..b165801351 100644 --- a/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts @@ -23,6 +23,7 @@ import { dirname, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { test } from 'node:test'; import { build } from 'esbuild'; +import type { ProjectRecord } from '@maka/core/project'; import type * as ProjectActions from '../../renderer/app-shell-project-actions.js'; const REPO_ROOT = resolve(import.meta.dirname, '../../../../..'); @@ -171,6 +172,91 @@ test('a Project mutation refresh stays bound to the operation Host', async () => } }); +test('Add on an archived directory: Host selection truth and UI claims must agree', async () => { + const actionsModule = await importProjectActions(); + const previousWindow = globalThis.window; + const catalog = new Map([ + ['project-a', { id: 'project-a', name: 'A', preferredPath: '/tmp/a', available: true } as ProjectRecord], + ]); + // First-principles ground truth: the Host owns the selected project. + const host = { selectedProjectId: 'project-a' as string | null }; + const successClaims: string[] = []; + const toastApi = { + success: (_title: string, description?: string) => { + successClaims.push(description ?? ''); + }, + error: () => {}, + confirm: async () => true, + }; + const fakeWindow = (hostSelects: boolean) => ({ + maka: { + runtimeHostProfiles: { + getDefaultHost: async () => ({ profileId: 'default-profile', hostId: 'default-host' }), + }, + projects: { + add: async () => ({ + ok: false as const, + reason: 'archived' as const, + projectId: 'project-b', + }), + restore: async (projectId: string) => { + const restored = { + id: projectId, + name: 'B', + preferredPath: '/tmp/b', + available: true, + } as ProjectRecord; + catalog.set(projectId, restored); + return restored; + }, + select: async (projectId: string | null) => { + if (!hostSelects) return { project: null, path: '' }; + host.selectedProjectId = projectId; + const selected = projectId === null ? null : catalog.get(projectId) ?? null; + return { project: selected, path: selected?.preferredPath ?? '' }; + }, + }, + app: { + resolveProjectGitInfo: async (projectPath: string) => ({ + ok: true as const, + projectPath, + projectGit: { isGitRepo: false }, + }), + }, + }, + }); + + try { + const actions = createTestProjectActions(actionsModule, { + projectCapabilities: { ...NO_PROJECT_CAPABILITIES, chooseClientDirectory: true }, + refreshDefaultProjectState: async () => [], + toastApi, + }); + + // User path: A selected, Add picks archived B, user confirms restore. + globalThis.window = fakeWindow(true) as unknown as Window & typeof globalThis; + const project = await actions.addProject(); + + // Truth: the Host now runs B, not A. + assert.equal(host.selectedProjectId, 'project-b'); + // Claims: the returned record and the success toast say the same thing. + assert.equal(project?.id, 'project-b'); + assert.deepEqual(successClaims, ['B']); + + // Counterfactual: the Host refuses selection, so truth stays A. + host.selectedProjectId = 'project-a'; + catalog.delete('project-b'); + successClaims.length = 0; + globalThis.window = fakeWindow(false) as unknown as Window & typeof globalThis; + + assert.equal(await actions.addProject(), null); + assert.equal(host.selectedProjectId, 'project-a'); + assert.deepEqual(successClaims, [], 'no success claim when Host truth did not change'); + } finally { + globalThis.window = previousWindow; + } +}); + async function importProjectActions(): Promise { const outdir = await mkdtemp(resolve(REPO_ROOT, 'apps/desktop/dist/main/__tests__/project-actions-')); const outfile = resolve(outdir, 'app-shell-project-actions.mjs'); diff --git a/apps/desktop/src/renderer/app-shell-project-actions.ts b/apps/desktop/src/renderer/app-shell-project-actions.ts index f449c04684..7b98249094 100644 --- a/apps/desktop/src/renderer/app-shell-project-actions.ts +++ b/apps/desktop/src/renderer/app-shell-project-actions.ts @@ -176,7 +176,7 @@ export function createAppShellProjectActions(deps: { }); if (!confirmed) return added; const restored = await window.maka.projects.restore(added.projectId, host); - await applySelectedProject(restored, restored.preferredPath ?? '', true, host); + if (!(await selectProjectRecord(restored, true, host))) return added; return { ok: true as const, project: restored, path: restored.preferredPath ?? '' }; } if (!added.ok) return added;