Stabilize OpenClaw artifact sync

This commit is contained in:
Haitao Pan 2026-06-08 10:49:09 +08:00
parent 7f665862e6
commit f034e6f28e
4 changed files with 226 additions and 4 deletions

View File

@ -871,9 +871,9 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
}
final thread = taskThreadForSessionInternal(normalizedSessionKey);
final association = thread?.openClawTaskAssociation;
final requiredExts =
thread?.openClawTaskAssociation?.requiredArtifactExtensions ??
const <String>[];
association?.requiredArtifactExtensions ?? const <String>[];
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 <String>[],
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<String> 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) {

View File

@ -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,
};
}

View File

@ -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 <String, String>{},
);
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: <String, dynamic>{
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'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 <String, String>{},
@ -1775,6 +1853,78 @@ void main() {
]);
});
test('uses default artifact-ignore policy for video skill outputs', () async {
final controller = AppController(
environmentOverride: const <String, String>{},
);
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<void>.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(<int>[1]);
await File(
'${localWorkspace.path}/build_segments/segment-01.mp4',
).writeAsBytes(<int>[2]);
await File(
'${localWorkspace.path}/snapshots/frame-01.png',
).writeAsBytes(<int>[3]);
await File(
'${localWorkspace.path}/renders/final.mp4',
).writeAsBytes(<int>[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 <String>['it-infra-evolution-video-v2'],
lifecycleStatus: 'running',
lastRunAtMs: startedAtMs,
lastResultCode: 'running',
);
const result = GoTaskServiceResult(
success: true,
message: 'done',
turnId: 'turn-1',
raw: <String, dynamic>{},
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, <String>['renders/final.mp4']);
});
test('records ordinary empty artifact results as no artifacts', () async {
final controller = AppController(
environmentOverride: const <String, String>{},

View File

@ -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(