From 62b461ef0ff1b53b4c4e13c9977f73cda9ab3deb Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 6 Jun 2026 14:50:16 +0800 Subject: [PATCH] fix: backfill OpenClaw artifacts on sidebar refresh --- ...app_controller_desktop_thread_actions.dart | 2 +- ...pp_controller_desktop_thread_sessions.dart | 137 +++++++++++++++++- ...troller_thread_workspace_binding_test.dart | 122 ++++++++++++++++ 3 files changed, 258 insertions(+), 3 deletions(-) diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index f55be97f..22af0be9 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -1372,7 +1372,7 @@ extension AppControllerDesktopThreadActions on AppController { lifecycleStatus: 'ready', lastRunAtMs: completedAtMs, lastResultCode: terminalResultCode, - clearOpenClawTaskAssociation: true, + clearOpenClawTaskAssociation: !hasCurrentRunArtifacts, updatedAtMs: completedAtMs, ); } diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index 1f8b2267..1eb31dfa 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -26,6 +26,7 @@ import '../runtime/codex_config_bridge.dart'; import '../runtime/code_agent_node_orchestrator.dart'; import '../runtime/assistant_artifacts.dart'; import '../runtime/desktop_thread_artifact_service.dart'; +import '../runtime/go_task_service_client.dart'; import '../runtime/mode_switcher.dart'; import '../runtime/agent_registry.dart'; import '../runtime/platform_environment.dart'; @@ -376,17 +377,89 @@ extension AppControllerDesktopThreadSessions on AppController { Future loadAssistantArtifactSnapshot({ String? sessionKey, - }) { + }) async { final resolvedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey ?? currentSessionKey, ); final thread = taskThreadForSessionInternal(resolvedSessionKey); - return threadArtifactServiceInternal.loadSnapshot( + final snapshot = await threadArtifactServiceInternal.loadSnapshot( workspacePath: assistantWorkspacePathForSession(resolvedSessionKey), workspaceKind: assistantWorkspaceKindForSession(resolvedSessionKey), artifactRelativePaths: thread?.lastTaskArtifactRelativePaths ?? const [], ); + if (snapshot.fileEntries.isNotEmpty || thread == null) { + return snapshot; + } + final synced = await syncRemoteTaskArtifactsForSessionInternal( + resolvedSessionKey, + ); + if (!synced) { + return snapshot; + } + final refreshedThread = taskThreadForSessionInternal(resolvedSessionKey); + return threadArtifactServiceInternal.loadSnapshot( + workspacePath: assistantWorkspacePathForSession(resolvedSessionKey), + workspaceKind: assistantWorkspaceKindForSession(resolvedSessionKey), + artifactRelativePaths: + refreshedThread?.lastTaskArtifactRelativePaths ?? const [], + ); + } + + Future syncRemoteTaskArtifactsForSessionInternal( + String sessionKey, + ) async { + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey, + ); + final thread = taskThreadForSessionInternal(normalizedSessionKey); + if (thread == null || + thread.workspaceBinding.workspaceKind != WorkspaceKind.localFs) { + return false; + } + final association = + thread.openClawTaskAssociation ?? + _inferOpenClawTaskAssociationFromThreadInternal(thread); + if (association == null) { + return false; + } + try { + final result = await goTaskServiceClientInternal.getTask( + route: GoTaskServiceRoute.externalAcpSingle, + target: assistantExecutionTargetFromExecutionMode( + thread.executionBinding.executionMode, + ), + association: association, + ); + if (result.artifacts.isEmpty) { + return false; + } + final status = result.status.trim(); + final nextAssociation = + result.openClawTaskAssociation ?? + association.copyWith(status: status.isEmpty ? 'completed' : status); + upsertTaskThreadInternal( + normalizedSessionKey, + openClawTaskAssociation: nextAssociation, + lastRemoteWorkingDirectory: result.remoteWorkingDirectory.trim().isEmpty + ? thread.lastRemoteWorkingDirectory + : result.remoteWorkingDirectory.trim(), + lastRemoteWorkspaceRefKind: + result.remoteWorkspaceRefKind ?? thread.lastRemoteWorkspaceRefKind, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await persistGoTaskArtifactsForSessionInternal( + normalizedSessionKey, + result, + ); + final refreshed = taskThreadForSessionInternal(normalizedSessionKey); + return refreshed?.lastTaskArtifactRelativePaths.isNotEmpty == true; + } catch (error) { + debugPrint( + 'Remote artifact sync failed for $normalizedSessionKey: $error', + ); + return false; + } } Future loadAssistantArtifactPreview( @@ -643,6 +716,66 @@ extension AppControllerDesktopThreadSessions on AppController { } } +OpenClawTaskAssociation? _inferOpenClawTaskAssociationFromThreadInternal( + TaskThread thread, +) { + final remoteWorkspace = thread.lastRemoteWorkingDirectory?.trim() ?? ''; + if (remoteWorkspace.isEmpty) { + return null; + } + final normalized = remoteWorkspace.replaceAll('\\', '/'); + final segments = normalized + .split('/') + .where((segment) => segment.trim().isNotEmpty) + .toList(growable: false); + final tasksIndex = segments.lastIndexOf('tasks'); + if (tasksIndex < 0 || tasksIndex + 2 >= segments.length) { + return null; + } + final taskDir = segments[tasksIndex + 1].trim(); + final runId = segments[tasksIndex + 2].trim(); + final openclawSessionKey = _openClawSessionKeyFromTaskDirInternal(taskDir); + final appThreadKey = _appThreadKeyFromSessionKeyInternal(thread.threadId); + if (runId.isEmpty || openclawSessionKey.isEmpty || appThreadKey.isEmpty) { + return null; + } + return OpenClawTaskAssociation( + sessionId: thread.threadId, + threadId: thread.threadId, + turnId: runId, + runId: runId, + artifactScope: remoteWorkspace, + artifactDirectory: remoteWorkspace, + gatewayProviderId: 'openclaw', + startedAtMs: thread.lifecycleState.lastRunAtMs ?? 0, + status: 'completed', + appThreadKey: appThreadKey, + openclawSessionKey: openclawSessionKey, + ); +} + +String _appThreadKeyFromSessionKeyInternal(String sessionKey) { + final normalized = sessionKey.trim(); + if (normalized.startsWith('draft:')) { + return normalized; + } + if (normalized.startsWith('draft-')) { + return 'draft:${normalized.substring('draft-'.length)}'; + } + return normalized; +} + +String _openClawSessionKeyFromTaskDirInternal(String taskDir) { + final normalized = taskDir.trim(); + if (normalized.startsWith('agent_main_draft_')) { + return 'agent:main:draft:${normalized.substring('agent_main_draft_'.length)}'; + } + if (normalized.startsWith('draft_')) { + return 'draft:${normalized.substring('draft_'.length)}'; + } + return ''; +} + AssistantExecutionTarget resolveAssistantExecutionTargetFromRecordForTest( TaskThread? record, { required AssistantExecutionTarget defaultExecutionTarget, diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index df385306..792d76ce 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -875,6 +875,71 @@ void main() { }, ); + test( + 'refreshing an empty artifact snapshot backfills OpenClaw task artifacts from the remote workspace hint', + () async { + late OpenClawTaskAssociation observedAssociation; + final goTaskClient = _ArtifactBackfillGoTaskServiceClient( + onGetTask: (association) { + observedAssociation = association; + }, + ); + final controller = AppController( + environmentOverride: const {}, + goTaskServiceClient: goTaskClient, + ); + addTearDown(controller.dispose); + + final taskWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-remote-backfill-workspace-', + ); + addTearDown(() async { + if (await taskWorkspace.exists()) { + await taskWorkspace.delete(recursive: true); + } + }); + + const sessionKey = 'draft-sample-sync'; + controller.upsertTaskThreadInternal( + sessionKey, + workspaceBinding: WorkspaceBinding( + workspaceId: sessionKey, + workspaceKind: WorkspaceKind.localFs, + workspacePath: taskWorkspace.path, + displayPath: taskWorkspace.path, + writable: true, + ), + lastRemoteWorkingDirectory: + '/home/ubuntu/.openclaw/workspace/tasks/' + 'agent_main_draft_sample-sync/turn-sample', + lastArtifactSyncStatus: 'no-artifacts', + lastTaskArtifactRelativePaths: const [], + ); + + final snapshot = await controller.loadAssistantArtifactSnapshot( + sessionKey: sessionKey, + ); + + expect(observedAssociation.appThreadKey, 'draft:sample-sync'); + expect( + observedAssociation.openclawSessionKey, + 'agent:main:draft:sample-sync', + ); + expect(observedAssociation.runId, 'turn-sample'); + expect( + snapshot.fileEntries.map((entry) => entry.relativePath), + contains('ai-news-report.md'), + ); + expect( + await File('${taskWorkspace.path}/ai-news-report.md').readAsString(), + '# AI news\n', + ); + final thread = controller.requireTaskThreadForSessionInternal(sessionKey); + expect(thread.lastArtifactSyncStatus, 'synced'); + expect(thread.openClawTaskAssociation?.runId, 'turn-sample'); + }, + ); + test( 'resumes bridge artifact downloads after a weak network disconnect', () async { @@ -1798,6 +1863,63 @@ class _RecordingSecureConfigStore extends SecureConfigStore { } } +class _ArtifactBackfillGoTaskServiceClient implements GoTaskServiceClient { + _ArtifactBackfillGoTaskServiceClient({required this.onGetTask}); + + final void Function(OpenClawTaskAssociation association) onGetTask; + + @override + Future executeTask( + GoTaskServiceRequest request, { + required void Function(GoTaskServiceUpdate update) onUpdate, + }) async { + throw UnimplementedError('executeTask is not used by this test'); + } + + @override + Future getTask({ + required AssistantExecutionTarget target, + required OpenClawTaskAssociation association, + required GoTaskServiceRoute route, + }) async { + onGetTask(association); + return GoTaskServiceResult( + success: true, + message: 'done', + turnId: association.turnId, + raw: { + 'success': true, + 'status': 'completed', + 'runId': association.runId, + 'remoteWorkingDirectory': association.artifactDirectory, + 'remoteWorkspaceRefKind': 'remotePath', + 'artifacts': >[ + { + 'relativePath': 'ai-news-report.md', + 'content': '# AI news\n', + 'contentType': 'text/markdown', + }, + ], + }, + errorMessage: '', + resolvedModel: '', + route: route, + ); + } + + @override + Future cancelTask({ + required GoTaskServiceRoute route, + required AssistantExecutionTarget target, + required String sessionId, + required String threadId, + OpenClawTaskAssociation? association, + }) async {} + + @override + Future dispose() async {} +} + Future _waitForControllerInitialization(AppController controller) async { final deadline = DateTime.now().add(const Duration(seconds: 5)); while (controller.initializing && DateTime.now().isBefore(deadline)) {