fix: backfill OpenClaw artifacts on sidebar refresh

This commit is contained in:
Haitao Pan 2026-06-06 14:50:16 +08:00
parent 63ca134465
commit 195827c67d
3 changed files with 258 additions and 3 deletions

View File

@ -1372,7 +1372,7 @@ extension AppControllerDesktopThreadActions on AppController {
lifecycleStatus: 'ready',
lastRunAtMs: completedAtMs,
lastResultCode: terminalResultCode,
clearOpenClawTaskAssociation: true,
clearOpenClawTaskAssociation: !hasCurrentRunArtifacts,
updatedAtMs: completedAtMs,
);
}

View File

@ -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<AssistantArtifactSnapshot> 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 <String>[],
);
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 <String>[],
);
}
Future<bool> 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<AssistantArtifactPreview> 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,

View File

@ -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 <String, String>{},
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 <String>[],
);
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<GoTaskServiceResult> executeTask(
GoTaskServiceRequest request, {
required void Function(GoTaskServiceUpdate update) onUpdate,
}) async {
throw UnimplementedError('executeTask is not used by this test');
}
@override
Future<GoTaskServiceResult> getTask({
required AssistantExecutionTarget target,
required OpenClawTaskAssociation association,
required GoTaskServiceRoute route,
}) async {
onGetTask(association);
return GoTaskServiceResult(
success: true,
message: 'done',
turnId: association.turnId,
raw: <String, dynamic>{
'success': true,
'status': 'completed',
'runId': association.runId,
'remoteWorkingDirectory': association.artifactDirectory,
'remoteWorkspaceRefKind': 'remotePath',
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'relativePath': 'ai-news-report.md',
'content': '# AI news\n',
'contentType': 'text/markdown',
},
],
},
errorMessage: '',
resolvedModel: '',
route: route,
);
}
@override
Future<void> cancelTask({
required GoTaskServiceRoute route,
required AssistantExecutionTarget target,
required String sessionId,
required String threadId,
OpenClawTaskAssociation? association,
}) async {}
@override
Future<void> dispose() async {}
}
Future<void> _waitForControllerInitialization(AppController controller) async {
final deadline = DateTime.now().add(const Duration(seconds: 5));
while (controller.initializing && DateTime.now().isBefore(deadline)) {