feat(openclaw): implement artifact sync and ignore policies
This commit is contained in:
parent
604536c11c
commit
b30228ef18
@ -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,
|
||||
|
||||
@ -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>{},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user