diff --git a/lib/app/app_controller_desktop_thread_binding.dart b/lib/app/app_controller_desktop_thread_binding.dart index db2b0be9..15421fa2 100644 --- a/lib/app/app_controller_desktop_thread_binding.dart +++ b/lib/app/app_controller_desktop_thread_binding.dart @@ -58,8 +58,9 @@ extension AppControllerDesktopThreadBinding on AppController { } final threadWorkspace = '${trimTrailingPathSeparatorInternal(baseWorkspace)}/.xworkmate/threads/${threadWorkspaceDirectoryNameInternal(normalizedSessionKey)}'; - ensureLocalWorkspaceDirectoryInternal(threadWorkspace); - return threadWorkspace; + return ensureLocalWorkspaceDirectoryInternal(threadWorkspace) + ? threadWorkspace + : ''; } String remoteThreadWorkspacePathInternal( @@ -93,16 +94,17 @@ extension AppControllerDesktopThreadBinding on AppController { return path; } - void ensureLocalWorkspaceDirectoryInternal(String path) { + bool ensureLocalWorkspaceDirectoryInternal(String path) { final normalizedPath = path.trim(); if (normalizedPath.isEmpty) { - return; + return false; } try { Directory(normalizedPath).createSync(recursive: true); } catch (_) { // Best effort only. The caller can still decide whether to fail fast. } + return Directory(normalizedPath).existsSync(); } ThreadOwnerScope desktopThreadOwnerScopeFromIdentityInternal( @@ -141,10 +143,13 @@ extension AppControllerDesktopThreadBinding on AppController { if (existingBinding != null && existingBinding.workspacePath.trim().isNotEmpty) { if (existingBinding.workspaceKind == WorkspaceKind.localFs) { - ensureLocalWorkspaceDirectoryInternal(existingBinding.workspacePath); - return existingBinding.copyWith( - displayPath: existingBinding.workspacePath, - ); + if (ensureLocalWorkspaceDirectoryInternal( + existingBinding.workspacePath, + )) { + return existingBinding.copyWith( + displayPath: existingBinding.workspacePath, + ); + } } final defaultRemotePath = remoteThreadWorkspacePathInternal( sessionKey, @@ -205,8 +210,7 @@ extension AppControllerDesktopThreadBinding on AppController { executionMode: switch (executionTarget) { AssistantExecutionTarget.singleAgent => ThreadExecutionMode.localAgent, - AssistantExecutionTarget.local => - ThreadExecutionMode.gatewayLocal, + AssistantExecutionTarget.local => ThreadExecutionMode.gatewayLocal, AssistantExecutionTarget.remote => ThreadExecutionMode.gatewayRemote, }, @@ -249,14 +253,15 @@ extension AppControllerDesktopThreadBinding on AppController { existing?.singleAgentProvider ?? SingleAgentProvider.auto, existingBinding: existing?.executionBinding, ), - lifecycleState: (existing?.lifecycleState ?? - const ThreadLifecycleState( - archived: false, - status: 'ready', - lastRunAtMs: null, - lastResultCode: null, - )) - .copyWith(status: lifecycleStatus), + lifecycleState: + (existing?.lifecycleState ?? + const ThreadLifecycleState( + archived: false, + status: 'ready', + lastRunAtMs: null, + lastResultCode: null, + )) + .copyWith(status: lifecycleStatus), executionTarget: resolvedExecutionTarget, updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); diff --git a/test/runtime/app_controller_assistant_workspace_ref_test.dart b/test/runtime/app_controller_assistant_workspace_ref_test.dart index 5083a2d3..86cd2ccd 100644 --- a/test/runtime/app_controller_assistant_workspace_ref_test.dart +++ b/test/runtime/app_controller_assistant_workspace_ref_test.dart @@ -66,8 +66,8 @@ void main() { AssistantExecutionTarget.remote, ); - final record = - controller.assistantThreadRecordsInternal[controller.currentSessionKey]!; + final record = controller + .assistantThreadRecordsInternal[controller.currentSessionKey]!; expect(record.ownerScope.realm, ThreadRealm.local); expect(record.ownerScope.subjectType, ThreadSubjectType.user); expect(record.ownerScope.subjectId, isNotEmpty); @@ -485,8 +485,8 @@ void main() { final controller = AppController(store: store); addTearDown(controller.dispose); await waitForControllerInternal(controller); - final existingMain = - controller.assistantThreadRecordsInternal[controller.currentSessionKey]!; + final existingMain = controller + .assistantThreadRecordsInternal[controller.currentSessionKey]!; controller.assistantThreadRecordsInternal[controller.currentSessionKey] = existingMain.copyWith( workspaceBinding: const WorkspaceBinding( @@ -504,25 +504,27 @@ void main() { await controller.setAssistantExecutionTarget( AssistantExecutionTarget.singleAgent, ); - controller.assistantThreadRecordsInternal[controller.currentSessionKey] = - controller - .assistantThreadRecordsInternal[controller.currentSessionKey]! - .copyWith( - workspaceBinding: const WorkspaceBinding( - workspaceId: 'main', - workspaceKind: WorkspaceKind.localFs, - workspacePath: '', - displayPath: '', - writable: true, - ), - lifecycleState: controller - .assistantThreadRecordsInternal[controller.currentSessionKey]! - .lifecycleState - .copyWith(status: 'needs_workspace'), - ); + controller.assistantThreadRecordsInternal[controller + .currentSessionKey] = controller + .assistantThreadRecordsInternal[controller.currentSessionKey]! + .copyWith( + workspaceBinding: const WorkspaceBinding( + workspaceId: 'main', + workspaceKind: WorkspaceKind.localFs, + workspacePath: '', + displayPath: '', + writable: true, + ), + lifecycleState: controller + .assistantThreadRecordsInternal[controller.currentSessionKey]! + .lifecycleState + .copyWith(status: 'needs_workspace'), + ); expect( - controller.assistantWorkspaceRefForSession(controller.currentSessionKey), + controller.assistantWorkspaceRefForSession( + controller.currentSessionKey, + ), isEmpty, ); expect( @@ -538,7 +540,9 @@ void main() { ); expect( - controller.assistantWorkspaceRefForSession(controller.currentSessionKey), + controller.assistantWorkspaceRefForSession( + controller.currentSessionKey, + ), '${workspaceRoot.path}/.xworkmate/threads/main', ); expect( @@ -556,4 +560,63 @@ void main() { ); }, ); + + test( + 'AppController keeps single-agent threads unbound when the workspace root cannot create thread directories', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-thread-workspace-invalid-root-', + ); + final invalidRootFile = File('${tempDirectory.path}/workspace-root-file'); + await invalidRootFile.writeAsString('not-a-directory'); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + workspacePath: invalidRootFile.path, + ), + ); + + final controller = AppController(store: store); + addTearDown(controller.dispose); + await waitForControllerInternal(controller); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); + + controller.initializeAssistantThreadContext( + 'draft:invalid-root', + title: 'Invalid Root', + executionTarget: AssistantExecutionTarget.singleAgent, + ); + + final expectedThreadWorkspace = Directory( + '${invalidRootFile.path}/.xworkmate/threads/draft-invalid-root', + ); + expect(await expectedThreadWorkspace.exists(), isFalse); + expect( + controller.assistantWorkspaceRefForSession('draft:invalid-root'), + isEmpty, + ); + expect( + controller + .assistantThreadRecordsInternal['draft:invalid-root'] + ?.lifecycleState + .status, + 'needs_workspace', + ); + }, + ); }