fix: backfill OpenClaw artifacts on sidebar refresh
This commit is contained in:
parent
63ca134465
commit
195827c67d
@ -1372,7 +1372,7 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
lifecycleStatus: 'ready',
|
||||
lastRunAtMs: completedAtMs,
|
||||
lastResultCode: terminalResultCode,
|
||||
clearOpenClawTaskAssociation: true,
|
||||
clearOpenClawTaskAssociation: !hasCurrentRunArtifacts,
|
||||
updatedAtMs: completedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user