diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 6a6bea6a..a02bd3f0 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -871,9 +871,9 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } final thread = taskThreadForSessionInternal(normalizedSessionKey); + final association = thread?.openClawTaskAssociation; final requiredExts = - thread?.openClawTaskAssociation?.requiredArtifactExtensions ?? - const []; + association?.requiredArtifactExtensions ?? const []; final missingRequired = requiredExts .where((ext) { return !currentTaskArtifactPaths.any( @@ -882,6 +882,31 @@ extension AppControllerDesktopRuntimeHelpers on AppController { }) .toList(growable: false); + final shouldKeepPollingAfterDownloadFailure = + !wroteArtifact && + failedArtifact && + result.success && + association != null && + (association.requiresArtifactExport || + association.requiredArtifactExtensions.isNotEmpty) && + (association.artifactScope.trim().isNotEmpty || + association.artifactDirectory.trim().isNotEmpty); + if (shouldKeepPollingAfterDownloadFailure) { + upsertTaskThreadInternal( + normalizedSessionKey, + lifecycleStatus: 'running', + lastResultCode: 'running', + lastArtifactSyncAtMs: syncedAtMs, + lastArtifactSyncStatus: 'syncing', + lastTaskArtifactRelativePaths: const [], + openClawTaskAssociation: association.copyWith( + status: 'syncing-artifacts', + ), + updatedAtMs: syncedAtMs, + ); + return; + } + final syncStatus = wroteArtifact ? (failedArtifact || skippedArtifact || missingRequired.isNotEmpty ? 'partial' @@ -1394,7 +1419,9 @@ Future<_ArtifactSyncPolicy> _loadArtifactSyncPolicyInternal( policyFiles.add(resolvedRelativePath); } } - final policies = <_ArtifactSyncPolicy>[]; + final policies = <_ArtifactSyncPolicy>[ + ..._defaultArtifactSyncPoliciesForSkillsInternal(selectedSkillKeys), + ]; try { for (final file in files) { if (!await file.exists()) { @@ -1408,6 +1435,33 @@ Future<_ArtifactSyncPolicy> _loadArtifactSyncPolicyInternal( return _ArtifactSyncPolicy.merge(policies, policyFiles: policyFiles); } +List<_ArtifactSyncPolicy> _defaultArtifactSyncPoliciesForSkillsInternal( + List selectedSkillKeys, +) { + final hasVideoSkill = selectedSkillKeys.any((skillKey) { + final normalized = _sanitizeArtifactRelativePathInternal( + skillKey, + ).toLowerCase(); + final segments = normalized.split('/'); + final leaf = segments.isEmpty ? normalized : segments.last; + return leaf == 'it-infra-evolution-video-v2'; + }); + if (!hasVideoSkill) { + return const <_ArtifactSyncPolicy>[]; + } + return <_ArtifactSyncPolicy>[ + _ArtifactSyncPolicy.parse( + '```artifact-ignore\n' + 'assets/audio/\n' + 'assets/images/\n' + 'build_segments/\n' + 'snapshots/\n' + 'tmp/\n' + '```\n', + ), + ]; +} + String _normalizeAuthorizationHeaderInternal(String raw) { final trimmed = raw.trim(); if (trimmed.isEmpty) { diff --git a/lib/runtime/runtime_models_runtime_payloads.dart b/lib/runtime/runtime_models_runtime_payloads.dart index 22bd942d..07de2ced 100644 --- a/lib/runtime/runtime_models_runtime_payloads.dart +++ b/lib/runtime/runtime_models_runtime_payloads.dart @@ -1001,6 +1001,16 @@ class OpenClawTaskAssociation { 'appThreadKey': appThreadKey, 'openclawSessionKey': openclawSessionKey, 'includeArtifacts': true, + if (artifactScope.trim().isNotEmpty) 'artifactScope': artifactScope, + if (artifactDirectory.trim().isNotEmpty) + 'artifactDirectory': artifactDirectory, + if (gatewayProviderId.trim().isNotEmpty) + 'gatewayProviderId': gatewayProviderId, + if (requiresArtifactExport) 'requiresArtifactExport': true, + if (expectedArtifactExtensions.isNotEmpty) + 'expectedArtifactExtensions': expectedArtifactExtensions, + if (requiredArtifactExtensions.isNotEmpty) + 'requiredArtifactExtensions': requiredArtifactExtensions, }; } diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index ca5e85ba..1c246217 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -1667,6 +1667,84 @@ void main() { expect(thread.lastTaskArtifactRelativePaths, isEmpty); }); + test( + 'keeps polling OpenClaw export after required artifact download fails', + () async { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + + final localWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-openclaw-required-download-failed-', + ); + addTearDown(() async { + if (await localWorkspace.exists()) { + await localWorkspace.delete(recursive: true); + } + }); + controller.upsertTaskThreadInternal( + 'unit-fixture-task-a', + workspaceBinding: WorkspaceBinding( + workspaceId: 'unit-fixture-task-a', + workspaceKind: WorkspaceKind.localFs, + workspacePath: localWorkspace.path, + displayPath: localWorkspace.path, + writable: true, + ), + openClawTaskAssociation: const OpenClawTaskAssociation( + sessionId: 'unit-fixture-task-a', + threadId: 'unit-fixture-task-a', + turnId: 'turn-1', + runId: 'turn-1', + artifactScope: 'tasks/agent_main_unit_fixture/turn-1', + artifactDirectory: '/remote/tasks/agent_main_unit_fixture/turn-1', + gatewayProviderId: 'openclaw', + startedAtMs: 1, + status: 'completed', + appThreadKey: 'unit-fixture-task-a', + openclawSessionKey: 'agent:main:unit-fixture', + requiresArtifactExport: true, + ), + ); + + final bytes = utf8.encode('# Final\n'); + final result = GoTaskServiceResult( + success: true, + message: 'done', + turnId: 'turn-1', + raw: { + 'artifacts': >[ + { + 'relativePath': 'exports/final.md', + 'contentType': 'text/markdown', + 'encoding': 'base64', + 'content': base64Encode(bytes), + 'sizeBytes': bytes.length, + 'sha256': '0' * 64, + }, + ], + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ); + + await controller.persistGoTaskArtifactsForSessionInternal( + 'unit-fixture-task-a', + result, + ); + + final thread = controller.requireTaskThreadForSessionInternal( + 'unit-fixture-task-a', + ); + expect(thread.lastArtifactSyncStatus, 'syncing'); + expect(thread.lifecycleState.lastResultCode, 'running'); + expect(thread.openClawTaskAssociation?.status, 'syncing-artifacts'); + expect(thread.lastTaskArtifactRelativePaths, isEmpty); + }, + ); + test('loads global and selected skill artifact-ignore policies', () async { final controller = AppController( environmentOverride: const {}, @@ -1775,6 +1853,78 @@ void main() { ]); }); + test('uses default artifact-ignore policy for video skill outputs', () async { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + + final localWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-video-default-artifact-policy-', + ); + addTearDown(() async { + if (await localWorkspace.exists()) { + await localWorkspace.delete(recursive: true); + } + }); + final startedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + await Future.delayed(const Duration(milliseconds: 20)); + await Directory( + '${localWorkspace.path}/assets/images', + ).create(recursive: true); + await Directory('${localWorkspace.path}/build_segments').create(); + await Directory('${localWorkspace.path}/snapshots').create(); + await Directory('${localWorkspace.path}/renders').create(); + await File( + '${localWorkspace.path}/assets/images/01.v8.png', + ).writeAsBytes([1]); + await File( + '${localWorkspace.path}/build_segments/segment-01.mp4', + ).writeAsBytes([2]); + await File( + '${localWorkspace.path}/snapshots/frame-01.png', + ).writeAsBytes([3]); + await File( + '${localWorkspace.path}/renders/final.mp4', + ).writeAsBytes([4]); + + controller.upsertTaskThreadInternal( + 'unit-fixture-task-a', + workspaceBinding: WorkspaceBinding( + workspaceId: 'unit-fixture-task-a', + workspaceKind: WorkspaceKind.localFs, + workspacePath: localWorkspace.path, + displayPath: localWorkspace.path, + writable: true, + ), + selectedSkillKeys: const ['it-infra-evolution-video-v2'], + lifecycleStatus: 'running', + lastRunAtMs: startedAtMs, + lastResultCode: 'running', + ); + + const result = GoTaskServiceResult( + success: true, + message: 'done', + turnId: 'turn-1', + raw: {}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ); + + await controller.persistGoTaskArtifactsForSessionInternal( + 'unit-fixture-task-a', + result, + ); + + final thread = controller.requireTaskThreadForSessionInternal( + 'unit-fixture-task-a', + ); + expect(thread.lastArtifactSyncStatus, 'synced'); + expect(thread.lastTaskArtifactRelativePaths, ['renders/final.mp4']); + }); + test('records ordinary empty artifact results as no artifacts', () async { final controller = AppController( environmentOverride: const {}, diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 4dd62a4e..bbf0accc 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -91,7 +91,15 @@ void main() { expect(params, isNot(contains('sessionKey'))); expect(params, isNot(contains('sessionId'))); expect(params, isNot(contains('threadId'))); - expect(params, isNot(contains('artifactScope'))); + expect( + params['artifactScope'], + 'tasks/agent:main:draft:1780658097668838-1/run-1', + ); + expect( + params['artifactDirectory'], + '/tmp/tasks/agent:main:draft:1780658097668838-1/run-1', + ); + expect(params['gatewayProviderId'], 'openclaw'); }); test(