feat(openclaw): implement artifact sync and ignore policies

This commit is contained in:
Haitao Pan 2026-06-02 11:14:07 +08:00
parent 604536c11c
commit b30228ef18
2 changed files with 501 additions and 7 deletions

View File

@ -758,15 +758,20 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
);
return;
}
final root = Directory(existingThread.workspaceBinding.workspacePath);
final artifactSyncPolicy = await _loadArtifactSyncPolicyInternal(
root,
existingThread.selectedSkillKeys,
);
final artifacts = result.artifacts;
if (artifacts.isEmpty) {
final root = Directory(existingThread.workspaceBinding.workspacePath);
final currentTaskArtifactRelativePaths =
isOpenClawNoExportedArtifactsGuardResultInternal(result)
? const <String>[]
: await _workspaceArtifactPathsModifiedSinceInternal(
root,
existingThread.lifecycleState.lastRunAtMs,
artifactSyncPolicy,
);
if (currentTaskArtifactRelativePaths.isNotEmpty) {
upsertTaskThreadInternal(
@ -789,18 +794,18 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
);
return;
}
final root = Directory(existingThread.workspaceBinding.workspacePath);
await root.create(recursive: true);
var wroteArtifact = false;
var failedArtifact = false;
var skippedArtifact = false;
var rejectedArtifact = false;
final currentTaskArtifactPaths = <String>{};
for (final artifact in artifacts) {
final relativePath = _sanitizeArtifactRelativePathInternal(
artifact.relativePath,
);
if (relativePath.isEmpty) {
if (relativePath.isEmpty || artifactSyncPolicy.ignores(relativePath)) {
skippedArtifact = true;
continue;
}
@ -811,7 +816,11 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
final bytes = bytesResult.bytes;
if (bytes == null) {
final existingArtifactPaths =
await _existingWorkspaceArtifactPathsInternal(root, relativePath);
await _existingWorkspaceArtifactPathsInternal(
root,
relativePath,
artifactSyncPolicy,
);
if (existingArtifactPaths.isEmpty) {
skippedArtifact = true;
continue;
@ -820,6 +829,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
wroteArtifact = true;
continue;
}
if (artifactSyncPolicy.rejects(artifact, relativePath, bytes)) {
rejectedArtifact = true;
continue;
}
final target = await _nextArtifactTargetFileInternal(root, relativePath);
await target.parent.create(recursive: true);
final verified = await _writeVerifiedArtifactBytesInternal(
@ -848,6 +861,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
? (failedArtifact || skippedArtifact ? 'partial' : 'synced')
: failedArtifact
? 'download-failed'
: rejectedArtifact
? 'no-exported-artifacts'
: 'no-artifacts';
final currentTaskArtifactRelativePaths = wroteArtifact
? (currentTaskArtifactPaths.toList(growable: false)..sort())
@ -1231,6 +1246,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
Future<List<String>> _existingWorkspaceArtifactPathsInternal(
Directory root,
String relativePath,
_ArtifactSyncPolicy policy,
) async {
final targetPath = DesktopThreadArtifactService.resolveAbsolutePathInternal(
root.path,
@ -1246,7 +1262,9 @@ Future<List<String>> _existingWorkspaceArtifactPathsInternal(
root.path,
targetPath,
);
return resolvedRelativePath == null || resolvedRelativePath.isEmpty
return resolvedRelativePath == null ||
resolvedRelativePath.isEmpty ||
policy.ignores(resolvedRelativePath)
? const <String>[]
: <String>[resolvedRelativePath];
}
@ -1260,7 +1278,9 @@ Future<List<String>> _existingWorkspaceArtifactPathsInternal(
for (final file in files) {
final resolvedRelativePath =
DesktopThreadArtifactService.relativePathInternal(root.path, file.path);
if (resolvedRelativePath != null && resolvedRelativePath.isNotEmpty) {
if (resolvedRelativePath != null &&
resolvedRelativePath.isNotEmpty &&
!policy.ignores(resolvedRelativePath)) {
paths.add(resolvedRelativePath);
}
}
@ -1271,6 +1291,7 @@ Future<List<String>> _existingWorkspaceArtifactPathsInternal(
Future<List<String>> _workspaceArtifactPathsModifiedSinceInternal(
Directory root,
double? sinceMs,
_ArtifactSyncPolicy policy,
) async {
final thresholdMs = sinceMs ?? 0;
if (thresholdMs <= 0 || !await root.exists()) {
@ -1281,7 +1302,7 @@ Future<List<String>> _workspaceArtifactPathsModifiedSinceInternal(
for (final file in files) {
try {
final stat = await file.stat();
if (stat.modified.millisecondsSinceEpoch.toDouble() < thresholdMs) {
if (stat.modified.millisecondsSinceEpoch.toDouble() <= thresholdMs) {
continue;
}
final resolvedRelativePath =
@ -1295,6 +1316,9 @@ Future<List<String>> _workspaceArtifactPathsModifiedSinceInternal(
if (_isWorkspaceArtifactNoisePathInternal(resolvedRelativePath)) {
continue;
}
if (policy.ignores(resolvedRelativePath)) {
continue;
}
paths.add(resolvedRelativePath);
} on FileSystemException {
continue;
@ -1309,6 +1333,54 @@ bool _isWorkspaceArtifactNoisePathInternal(String relativePath) {
'.DS_Store';
}
Future<_ArtifactSyncPolicy> _loadArtifactSyncPolicyInternal(
Directory root,
List<String> selectedSkillKeys,
) async {
final files = <File>[
File(
DesktopThreadArtifactService.resolveAbsolutePathInternal(
root.path,
'artifact-ignore.md',
),
),
];
for (final skillKey in selectedSkillKeys) {
final normalizedSkillKey = _sanitizeArtifactRelativePathInternal(skillKey);
if (normalizedSkillKey.isEmpty) {
continue;
}
files.add(
File(
DesktopThreadArtifactService.resolveAbsolutePathInternal(
root.path,
'skills/$normalizedSkillKey/artifact-ignore.md',
),
),
);
}
final policyFiles = <String>[];
for (final file in files) {
final resolvedRelativePath =
DesktopThreadArtifactService.relativePathInternal(root.path, file.path);
if (resolvedRelativePath != null && resolvedRelativePath.isNotEmpty) {
policyFiles.add(resolvedRelativePath);
}
}
final policies = <_ArtifactSyncPolicy>[];
try {
for (final file in files) {
if (!await file.exists()) {
continue;
}
policies.add(_ArtifactSyncPolicy.parse(await file.readAsString()));
}
} on FileSystemException {
return const _ArtifactSyncPolicy();
}
return _ArtifactSyncPolicy.merge(policies, policyFiles: policyFiles);
}
String _normalizeAuthorizationHeaderInternal(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {
@ -1356,6 +1428,238 @@ class _ArtifactBytesResult {
final bool failed;
}
class _ArtifactSyncPolicy {
const _ArtifactSyncPolicy({
this.ignoreRules = const <_ArtifactIgnoreRule>[],
this.rejectRules = const <_ArtifactRejectRule>[],
this.policyFiles = const <String>[],
});
factory _ArtifactSyncPolicy.merge(
List<_ArtifactSyncPolicy> policies, {
required List<String> policyFiles,
}) {
return _ArtifactSyncPolicy(
ignoreRules: policies
.expand((policy) => policy.ignoreRules)
.toList(growable: false),
rejectRules: policies
.expand((policy) => policy.rejectRules)
.toList(growable: false),
policyFiles: policyFiles,
);
}
factory _ArtifactSyncPolicy.parse(String markdown) {
final ignoreRules = <_ArtifactIgnoreRule>[];
final rejectRules = <_ArtifactRejectRule>[];
var inIgnoreBlock = false;
var inRejectBlock = false;
var fields = <String, List<String>>{};
for (final rawLine in markdown.split(RegExp(r'\r?\n'))) {
final line = rawLine.trim();
if (line.startsWith('```')) {
final fenceName = line
.replaceFirst(RegExp(r'^`+'), '')
.trim()
.toLowerCase();
if (!inIgnoreBlock &&
!inRejectBlock &&
fenceName == 'artifact-ignore') {
inIgnoreBlock = true;
continue;
}
if (!inRejectBlock && fenceName == 'artifact-reject') {
inRejectBlock = true;
fields = <String, List<String>>{};
continue;
}
if (inIgnoreBlock) {
inIgnoreBlock = false;
}
if (inRejectBlock) {
final rule = _ArtifactRejectRule.tryParse(fields);
if (rule != null) {
rejectRules.add(rule);
}
inRejectBlock = false;
fields = <String, List<String>>{};
}
continue;
}
if (inIgnoreBlock) {
if (line.isEmpty || line.startsWith('#')) {
continue;
}
final rule = _ArtifactIgnoreRule.tryParse(line);
if (rule != null) {
ignoreRules.add(rule);
}
continue;
}
if (!inRejectBlock || line.isEmpty || line.startsWith('#')) {
continue;
}
final separator = line.indexOf('=');
if (separator <= 0) {
continue;
}
final key = line.substring(0, separator).trim().toLowerCase();
final value = line.substring(separator + 1).trim();
if (key.isEmpty || value.isEmpty) {
continue;
}
fields.putIfAbsent(key, () => <String>[]).add(value);
}
return _ArtifactSyncPolicy(
ignoreRules: ignoreRules,
rejectRules: rejectRules,
);
}
final List<_ArtifactIgnoreRule> ignoreRules;
final List<_ArtifactRejectRule> rejectRules;
final List<String> policyFiles;
bool ignores(String relativePath) {
if (_isWorkspaceArtifactNoisePathInternal(relativePath)) {
return true;
}
final normalizedPath = _sanitizeArtifactRelativePathInternal(relativePath);
if (DesktopThreadArtifactService.baseNameInternal(normalizedPath) ==
'artifact-ignore.md' ||
policyFiles.contains(normalizedPath)) {
return true;
}
for (final rule in ignoreRules) {
if (rule.matches(normalizedPath)) {
return true;
}
}
return false;
}
bool rejects(
GoTaskServiceArtifact artifact,
String relativePath,
List<int> bytes,
) {
for (final rule in rejectRules) {
if (rule.matches(artifact, relativePath, bytes)) {
return true;
}
}
return false;
}
}
class _ArtifactIgnoreRule {
const _ArtifactIgnoreRule(this.pattern);
static _ArtifactIgnoreRule? tryParse(String raw) {
final pattern = _sanitizeArtifactRelativePathInternal(raw);
if (pattern.isEmpty) {
return null;
}
return _ArtifactIgnoreRule(raw.trim());
}
final String pattern;
bool matches(String relativePath) {
final normalizedPath = _sanitizeArtifactRelativePathInternal(
relativePath,
).toLowerCase();
final trimmedPattern = pattern.trim();
if (trimmedPattern.endsWith('/')) {
final directoryPattern = _sanitizeArtifactRelativePathInternal(
trimmedPattern.substring(0, trimmedPattern.length - 1),
).toLowerCase();
return normalizedPath == directoryPattern ||
normalizedPath.startsWith('$directoryPattern/');
}
return _matchesArtifactPathPatternInternal(normalizedPath, trimmedPattern);
}
}
class _ArtifactRejectRule {
const _ArtifactRejectRule({
required this.path,
required this.contentType,
required this.contains,
});
static _ArtifactRejectRule? tryParse(Map<String, List<String>> fields) {
final contains = fields['contains'] ?? const <String>[];
if (contains.isEmpty) {
return null;
}
return _ArtifactRejectRule(
path: _firstValue(fields['path']),
contentType: _firstValue(fields['contenttype']),
contains: contains,
);
}
final String? path;
final String? contentType;
final List<String> contains;
static String? _firstValue(List<String>? values) {
if (values == null || values.isEmpty) {
return null;
}
return values.first;
}
bool matches(
GoTaskServiceArtifact artifact,
String relativePath,
List<int> bytes,
) {
if (path != null &&
!_matchesArtifactPathPatternInternal(relativePath, path!)) {
return false;
}
if (contentType != null &&
!artifact.contentType.trim().toLowerCase().contains(
contentType!.trim().toLowerCase(),
)) {
return false;
}
final text = utf8.decode(bytes, allowMalformed: true);
for (final needle in contains) {
if (!text.contains(needle)) {
return false;
}
}
return true;
}
}
bool _matchesArtifactPathPatternInternal(String relativePath, String pattern) {
final normalizedPath = _sanitizeArtifactRelativePathInternal(
relativePath,
).toLowerCase();
final normalizedPattern = _sanitizeArtifactRelativePathInternal(
pattern,
).toLowerCase();
if (normalizedPath.isEmpty || normalizedPattern.isEmpty) {
return false;
}
if (normalizedPattern == normalizedPath) {
return true;
}
if (normalizedPattern.startsWith('*.')) {
return !normalizedPath.contains('/') &&
normalizedPath.endsWith(normalizedPattern.substring(1));
}
if (normalizedPattern.startsWith('**/*.')) {
return normalizedPath.endsWith(normalizedPattern.substring(4));
}
return false;
}
class _ArtifactDownloadAttemptResult {
const _ArtifactDownloadAttemptResult({
required this.bytes,

View File

@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart' as crypto;
@ -1354,6 +1355,195 @@ void main() {
},
);
test('rejects artifacts matched by artifact-ignore policy', () async {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
final localWorkspace = await Directory.systemTemp.createTemp(
'xworkmate-openclaw-placeholder-pdf-',
);
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,
),
);
await File('${localWorkspace.path}/artifact-ignore.md').writeAsString(
'```artifact-reject\n'
'path=exports/final.pdf\n'
'contentType=application/pdf\n'
'contains=XWorkmate Task Artifact\n'
'contains=Required extensions: pdf\n'
'contains=TaskThread workspace context:\n'
'contains=Workspace isolation rules:\n'
'```\n',
);
final placeholderBytes = utf8.encode(
'%PDF-1.3\n'
'BT /F1 14 Tf (XWorkmate Task Artifact) Tj '
'(Required extensions: pdf) Tj '
'(TaskThread workspace context:) Tj '
'(Workspace isolation rules:) Tj ET',
);
final result = GoTaskServiceResult(
success: true,
message:
'OpenClaw final artifacts were written to the current task artifact scope: pdf.',
turnId: 'turn-1',
raw: <String, dynamic>{
'artifactWarnings': <String>['agent.wait request timeout'],
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'relativePath': 'exports/final.pdf',
'contentType': 'application/pdf',
'encoding': 'base64',
'content': base64Encode(placeholderBytes),
'sizeBytes': placeholderBytes.length,
'sha256': crypto.sha256.convert(placeholderBytes).toString(),
},
],
},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
);
await controller.persistGoTaskArtifactsForSessionInternal(
'unit-fixture-task-a',
result,
);
expect(
await File('${localWorkspace.path}/exports/final.pdf').exists(),
isFalse,
);
final thread = controller.requireTaskThreadForSessionInternal(
'unit-fixture-task-a',
);
expect(thread.lastArtifactSyncStatus, 'no-exported-artifacts');
expect(thread.lastTaskArtifactRelativePaths, isEmpty);
});
test('loads global and selected skill artifact-ignore policies', () async {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
final localWorkspace = await Directory.systemTemp.createTemp(
'xworkmate-skill-artifact-policy-',
);
addTearDown(() async {
if (await localWorkspace.exists()) {
await localWorkspace.delete(recursive: true);
}
});
await Directory(
'${localWorkspace.path}/skills/video-production/it-infra-evolution-video-v2',
).create(recursive: true);
await File('${localWorkspace.path}/artifact-ignore.md').writeAsString(
'```artifact-ignore\n'
'tmp/\n'
'```\n',
);
await File(
'${localWorkspace.path}/skills/video-production/it-infra-evolution-video-v2/artifact-ignore.md',
).writeAsString(
'```artifact-ignore\n'
'renders/tmp/\n'
'```\n',
);
final startedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble();
await Future<void>.delayed(const Duration(milliseconds: 20));
await Directory('${localWorkspace.path}/tmp').create();
await Directory(
'${localWorkspace.path}/renders/tmp',
).create(recursive: true);
await Directory('${localWorkspace.path}/renders').create();
await File('${localWorkspace.path}/tmp/build.log').writeAsString('log');
await File(
'${localWorkspace.path}/renders/tmp/scratch.png',
).writeAsBytes(<int>[1, 2, 3]);
await File(
'${localWorkspace.path}/renders/final.mp4',
).writeAsBytes(<int>[4, 5, 6]);
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>[
'video-production/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']);
controller.upsertTaskThreadInternal(
'unit-fixture-task-b',
workspaceBinding: WorkspaceBinding(
workspaceId: 'unit-fixture-task-b',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
writable: true,
),
selectedSkillKeys: const <String>[],
lifecycleStatus: 'running',
lastRunAtMs: startedAtMs,
lastResultCode: 'running',
);
await controller.persistGoTaskArtifactsForSessionInternal(
'unit-fixture-task-b',
result,
);
final unselectedSkillThread = controller
.requireTaskThreadForSessionInternal('unit-fixture-task-b');
expect(unselectedSkillThread.lastTaskArtifactRelativePaths, <String>[
'renders/final.mp4',
'renders/tmp/scratch.png',
]);
});
test('records ordinary empty artifact results as no artifacts', () async {
final controller = AppController(
environmentOverride: const <String, String>{},