fix: isolate task artifacts by current sync

This commit is contained in:
Haitao Pan 2026-05-09 17:01:04 +08:00
parent 104486a870
commit 564fa533ac
6 changed files with 255 additions and 17 deletions

View File

@ -729,6 +729,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
normalizedSessionKey,
lastArtifactSyncAtMs: syncedAtMs,
lastArtifactSyncStatus: 'syncing',
lastTaskArtifactRelativePaths: const <String>[],
updatedAtMs: syncedAtMs,
);
recomputeTasksInternal();
@ -762,6 +763,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
var wroteArtifact = false;
var failedArtifact = false;
var skippedArtifact = false;
final currentTaskArtifactRelativePaths = <String>[];
for (final artifact in artifacts) {
final relativePath = _sanitizeArtifactRelativePathInternal(
artifact.relativePath,
@ -791,6 +793,14 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
continue;
}
wroteArtifact = true;
final writtenRelativePath =
DesktopThreadArtifactService.relativePathInternal(
root.path,
target.path,
);
if (writtenRelativePath != null && writtenRelativePath.isNotEmpty) {
currentTaskArtifactRelativePaths.add(writtenRelativePath);
}
}
final syncStatus = wroteArtifact
@ -802,6 +812,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
normalizedSessionKey,
lastArtifactSyncAtMs: syncedAtMs,
lastArtifactSyncStatus: syncStatus,
lastTaskArtifactRelativePaths: currentTaskArtifactRelativePaths,
updatedAtMs: syncedAtMs,
);
}

View File

@ -242,6 +242,7 @@ extension AppControllerDesktopSkillPermissions on AppController {
WorkspaceRefKind? lastRemoteWorkspaceRefKind,
double? lastArtifactSyncAtMs,
String? lastArtifactSyncStatus,
List<String>? lastTaskArtifactRelativePaths,
}) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
@ -359,6 +360,7 @@ extension AppControllerDesktopSkillPermissions on AppController {
lastRemoteWorkspaceRefKind: null,
lastArtifactSyncAtMs: null,
lastArtifactSyncStatus: null,
lastTaskArtifactRelativePaths: const <String>[],
))
.copyWith(
messages: nextMessages,
@ -382,6 +384,7 @@ extension AppControllerDesktopSkillPermissions on AppController {
lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind,
lastArtifactSyncAtMs: lastArtifactSyncAtMs,
lastArtifactSyncStatus: lastArtifactSyncStatus,
lastTaskArtifactRelativePaths: lastTaskArtifactRelativePaths,
);
final nextStatus =
lifecycleStatus ??

View File

@ -223,9 +223,7 @@ extension AppControllerDesktopThreadSessions on AppController {
?.selectedSkillKeys ??
const <String>[];
final availableKeys = skills.map((item) => item.skillKey).toSet();
return selected
.where(availableKeys.contains)
.toList(growable: false);
return selected.where(availableKeys.contains).toList(growable: false);
}
String assistantModelForSession(String sessionKey) {
@ -327,9 +325,12 @@ extension AppControllerDesktopThreadSessions on AppController {
final resolvedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey ?? currentSessionKey,
);
final thread = taskThreadForSessionInternal(resolvedSessionKey);
return threadArtifactServiceInternal.loadSnapshot(
workspacePath: assistantWorkspacePathForSession(resolvedSessionKey),
workspaceKind: assistantWorkspaceKindForSession(resolvedSessionKey),
artifactRelativePaths:
thread?.lastTaskArtifactRelativePaths ?? const <String>[],
);
}
@ -340,10 +341,13 @@ extension AppControllerDesktopThreadSessions on AppController {
final resolvedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey ?? currentSessionKey,
);
final thread = taskThreadForSessionInternal(resolvedSessionKey);
return threadArtifactServiceInternal.loadPreview(
entry: entry,
workspacePath: assistantWorkspacePathForSession(resolvedSessionKey),
workspaceKind: assistantWorkspaceKindForSession(resolvedSessionKey),
artifactRelativePaths:
thread?.lastTaskArtifactRelativePaths ?? const <String>[],
);
}

View File

@ -19,6 +19,7 @@ class DesktopThreadArtifactService {
Future<AssistantArtifactSnapshot> loadSnapshot({
required String workspacePath,
required WorkspaceRefKind workspaceKind,
List<String> artifactRelativePaths = const <String>[],
}) async {
final normalizedRef = workspacePath.trim();
if (normalizedRef.isEmpty) {
@ -56,9 +57,24 @@ class DesktopThreadArtifactService {
);
}
final files = await collectFilesInternal(root);
final taskArtifactPaths = normalizeTaskArtifactPathsInternal(
artifactRelativePaths,
);
final files = taskArtifactPaths.isEmpty
? const <File>[]
: await collectTaskArtifactFilesInternal(
root,
normalizedRef,
taskArtifactPaths,
);
final fileEntries = await buildEntriesInternal(files, normalizedRef);
final changes = await readGitChangesInternal(root, normalizedRef);
final changes = taskArtifactPaths.isEmpty
? const <AssistantArtifactChangeEntry>[]
: await readGitChangesInternal(
root,
normalizedRef,
artifactRelativePaths: taskArtifactPaths,
);
final results = await buildResultEntriesInternal(
changes: changes,
fileEntries: fileEntries,
@ -93,6 +109,7 @@ class DesktopThreadArtifactService {
required AssistantArtifactEntry entry,
required String workspacePath,
required WorkspaceRefKind workspaceKind,
List<String> artifactRelativePaths = const <String>[],
}) async {
if (workspaceKind != WorkspaceRefKind.localPath) {
return const AssistantArtifactPreview.empty(
@ -106,9 +123,19 @@ class DesktopThreadArtifactService {
'The recorded working directory is not available on this machine.',
);
}
final taskArtifactPaths = normalizeTaskArtifactPathsInternal(
artifactRelativePaths,
);
final entryRelativePath = normalizeArtifactPathInternal(entry.relativePath);
if (entryRelativePath.isEmpty ||
!taskArtifactPaths.contains(entryRelativePath)) {
return const AssistantArtifactPreview.empty(
message: 'The selected file is not part of the current task artifacts.',
);
}
final targetPath = resolveAbsolutePathInternal(
workspacePath,
entry.relativePath,
entryRelativePath,
);
final file = File(targetPath);
if (!await file.exists()) {
@ -117,8 +144,15 @@ class DesktopThreadArtifactService {
'The selected file is no longer available: ${entry.relativePath}',
);
}
final resolvedRelativePath = relativePathInternal(workspacePath, file.path);
if (resolvedRelativePath == null ||
resolvedRelativePath != entryRelativePath) {
return const AssistantArtifactPreview.empty(
message: 'The selected file is not part of the current task artifacts.',
);
}
final extension = fileExtensionInternal(entry.relativePath);
final extension = fileExtensionInternal(entryRelativePath);
final content = await file.readAsString();
final title = entry.label;
if (extension == 'md' || extension == 'markdown') {
@ -171,6 +205,33 @@ class DesktopThreadArtifactService {
return files;
}
Future<List<File>> collectTaskArtifactFilesInternal(
Directory root,
String workspacePath,
List<String> artifactRelativePaths,
) async {
final files = <File>[];
for (final relativePath in artifactRelativePaths) {
final target = File(resolveAbsolutePathInternal(root.path, relativePath));
try {
if (!await target.exists()) {
continue;
}
final resolvedRelativePath = relativePathInternal(
workspacePath,
target.path,
);
if (resolvedRelativePath == null || resolvedRelativePath.isEmpty) {
continue;
}
files.add(target);
} on FileSystemException {
continue;
}
}
return files;
}
Future<List<AssistantArtifactEntry>> buildEntriesInternal(
List<File> files,
String workspacePath,
@ -232,8 +293,12 @@ class DesktopThreadArtifactService {
Future<List<AssistantArtifactChangeEntry>> readGitChangesInternal(
Directory workspaceRoot,
String workspacePath,
) async {
String workspacePath, {
List<String> artifactRelativePaths = const <String>[],
}) async {
final allowedPaths = normalizeTaskArtifactPathsInternal(
artifactRelativePaths,
).toSet();
String? repositoryRoot;
try {
final revParse = await Process.run('git', <String>[
@ -279,6 +344,9 @@ class DesktopThreadArtifactService {
if (relativePath == null || relativePath.isEmpty) {
continue;
}
if (allowedPaths.isNotEmpty && !allowedPaths.contains(relativePath)) {
continue;
}
items.add(
AssistantArtifactChangeEntry(
path: relativePath,
@ -369,6 +437,39 @@ class DesktopThreadArtifactService {
return normalizedPath.substring(prefix.length);
}
static List<String> normalizeTaskArtifactPathsInternal(
List<String> relativePaths,
) {
final seen = <String>{};
final normalized = <String>[];
for (final relativePath in relativePaths) {
final item = normalizeArtifactPathInternal(relativePath);
if (item.isEmpty || !seen.add(item)) {
continue;
}
normalized.add(item);
}
return normalized;
}
static String normalizeArtifactPathInternal(String relativePath) {
final trimmed = relativePath.trim().replaceAll('\\', '/');
if (trimmed.isEmpty ||
trimmed.startsWith('/') ||
trimmed.startsWith('~') ||
trimmed.contains(':')) {
return '';
}
final segments = trimmed
.split('/')
.where((segment) => segment.isNotEmpty && segment != '.')
.toList(growable: false);
if (segments.isEmpty || segments.any((segment) => segment == '..')) {
return '';
}
return segments.join('/');
}
static String normalizePathInternal(String path) {
try {
final type = FileSystemEntity.typeSync(path, followLinks: true);

View File

@ -741,6 +741,7 @@ class ThreadContextState {
this.lastRemoteWorkspaceRefKind,
this.lastArtifactSyncAtMs,
this.lastArtifactSyncStatus,
this.lastTaskArtifactRelativePaths = const <String>[],
});
final List<GatewayChatMessage> messages;
@ -758,6 +759,7 @@ class ThreadContextState {
final WorkspaceRefKind? lastRemoteWorkspaceRefKind;
final double? lastArtifactSyncAtMs;
final String? lastArtifactSyncStatus;
final List<String> lastTaskArtifactRelativePaths;
ThreadContextState copyWith({
List<GatewayChatMessage>? messages,
@ -776,6 +778,7 @@ class ThreadContextState {
WorkspaceRefKind? lastRemoteWorkspaceRefKind,
double? lastArtifactSyncAtMs,
String? lastArtifactSyncStatus,
List<String>? lastTaskArtifactRelativePaths,
}) {
return ThreadContextState(
messages: messages ?? this.messages,
@ -800,6 +803,9 @@ class ThreadContextState {
lastArtifactSyncAtMs: lastArtifactSyncAtMs ?? this.lastArtifactSyncAtMs,
lastArtifactSyncStatus:
lastArtifactSyncStatus ?? this.lastArtifactSyncStatus,
lastTaskArtifactRelativePaths: lastTaskArtifactRelativePaths == null
? this.lastTaskArtifactRelativePaths
: _stringListFromJson(lastTaskArtifactRelativePaths),
);
}
@ -822,6 +828,7 @@ class ThreadContextState {
'lastRemoteWorkspaceRefKind': lastRemoteWorkspaceRefKind?.name,
'lastArtifactSyncAtMs': lastArtifactSyncAtMs,
'lastArtifactSyncStatus': lastArtifactSyncStatus,
'lastTaskArtifactRelativePaths': lastTaskArtifactRelativePaths,
};
}
@ -897,10 +904,34 @@ class ThreadContextState {
})(),
lastArtifactSyncAtMs: asDouble(json['lastArtifactSyncAtMs']),
lastArtifactSyncStatus: json['lastArtifactSyncStatus']?.toString(),
lastTaskArtifactRelativePaths: _stringListFromJson(
json['lastTaskArtifactRelativePaths'],
),
);
}
}
List<String> _stringListFromJson(Object? value) {
if (value is! List) {
return const <String>[];
}
final seen = <String>{};
final items = <String>[];
for (final item in value) {
final normalized = item?.toString().trim().replaceAll('\\', '/') ?? '';
if (normalized.isEmpty || normalized.startsWith('/')) {
continue;
}
if (normalized.split('/').any((segment) => segment == '..')) {
continue;
}
if (seen.add(normalized)) {
items.add(normalized);
}
}
return items;
}
class ThreadLifecycleState {
const ThreadLifecycleState({
required this.archived,
@ -984,6 +1015,7 @@ class TaskThread {
WorkspaceRefKind? lastRemoteWorkspaceRefKind,
double? lastArtifactSyncAtMs,
String? lastArtifactSyncStatus,
List<String>? lastTaskArtifactRelativePaths,
}) : threadId = _resolveThreadId(threadId),
title = title ?? '',
ownerScope =
@ -1029,6 +1061,9 @@ class TaskThread {
lastArtifactSyncStatus?.trim().isNotEmpty == true
? lastArtifactSyncStatus!.trim()
: null,
lastTaskArtifactRelativePaths: _stringListFromJson(
lastTaskArtifactRelativePaths,
),
),
lifecycleState =
lifecycleState ??
@ -1067,6 +1102,8 @@ class TaskThread {
contextState.lastRemoteWorkspaceRefKind;
double? get lastArtifactSyncAtMs => contextState.lastArtifactSyncAtMs;
String? get lastArtifactSyncStatus => contextState.lastArtifactSyncStatus;
List<String> get lastTaskArtifactRelativePaths =>
contextState.lastTaskArtifactRelativePaths;
String get latestResolvedRuntimeModel =>
contextState.latestResolvedRuntimeModel;
String get latestResolvedProviderId => contextState.latestResolvedProviderId;
@ -1110,6 +1147,7 @@ class TaskThread {
WorkspaceRefKind? lastRemoteWorkspaceRefKind,
double? lastArtifactSyncAtMs,
String? lastArtifactSyncStatus,
List<String>? lastTaskArtifactRelativePaths,
}) {
return TaskThread(
threadId: threadId ?? this.threadId,
@ -1133,6 +1171,7 @@ class TaskThread {
lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind,
lastArtifactSyncAtMs: lastArtifactSyncAtMs,
lastArtifactSyncStatus: lastArtifactSyncStatus,
lastTaskArtifactRelativePaths: lastTaskArtifactRelativePaths,
),
lifecycleState: (lifecycleState ?? this.lifecycleState).copyWith(
archived: archived,
@ -1249,6 +1288,7 @@ class TaskThread {
'lastRemoteWorkspaceRefKind': json['lastRemoteWorkspaceRefKind'],
'lastArtifactSyncAtMs': json['lastArtifactSyncAtMs'],
'lastArtifactSyncStatus': json['lastArtifactSyncStatus'],
'lastTaskArtifactRelativePaths': json['lastTaskArtifactRelativePaths'],
};
}

View File

@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/app/app_controller_desktop_runtime_coordination_impl.dart';
import 'package:xworkmate/app/app_controller_desktop_thread_binding.dart';
import 'package:xworkmate/runtime/assistant_artifacts.dart';
import 'package:xworkmate/runtime/go_task_service_client.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
@ -196,14 +197,9 @@ void main() {
final snapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: 'session-1',
);
expect(
snapshot.fileEntries.map((entry) => entry.relativePath),
contains('notes/hello.txt'),
);
expect(
snapshot.fileEntries.map((entry) => entry.relativePath),
contains('notes/hello.v2.txt'),
);
expect(snapshot.fileEntries.map((entry) => entry.relativePath), <String>[
'notes/hello.v2.txt',
]);
expect(
controller
.requireTaskThreadForSessionInternal('session-1')
@ -212,6 +208,83 @@ void main() {
);
});
test(
'limits the artifact panel to files produced by the current task sync',
() async {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
final localWorkspace = await Directory.systemTemp.createTemp(
'xworkmate-isolated-artifact-workspace-',
);
addTearDown(() async {
if (await localWorkspace.exists()) {
await localWorkspace.delete(recursive: true);
}
});
final staleArtifact = File('${localWorkspace.path}/old-task-report.md');
await staleArtifact.writeAsString('stale task output');
controller.upsertTaskThreadInternal(
'session-1',
workspaceBinding: WorkspaceBinding(
workspaceId: 'session-1',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
writable: true,
),
);
final result = GoTaskServiceResult(
success: true,
message: 'hello',
turnId: 'turn-2',
raw: <String, dynamic>{
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'relativePath': 'current-task-report.md',
'content': 'current task output',
'contentType': 'text/markdown',
},
],
},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
);
await controller.persistGoTaskArtifactsForSessionInternal(
'session-1',
result,
);
final snapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: 'session-1',
);
final relativePaths = snapshot.fileEntries
.map((entry) => entry.relativePath)
.toList(growable: false);
expect(relativePaths, <String>['current-task-report.md']);
final stalePreview = await controller.loadAssistantArtifactPreview(
AssistantArtifactEntry(
id: '${localWorkspace.path}::old-task-report.md',
label: 'old-task-report.md',
relativePath: 'old-task-report.md',
kind: AssistantArtifactEntryKind.file,
mimeType: 'text/markdown',
previewable: true,
workspacePath: localWorkspace.path,
),
sessionKey: 'session-1',
);
expect(stalePreview.kind, AssistantArtifactPreviewKind.empty);
},
);
test(
'downloads bridge URL artifacts into the local thread workspace',
() async {
@ -895,6 +968,8 @@ void main() {
await localWorkspace.delete(recursive: true);
}
});
final staleArtifact = File('${localWorkspace.path}/old-task-report.md');
await staleArtifact.writeAsString('stale task output');
controller.upsertTaskThreadInternal(
'session-1',
workspaceBinding: WorkspaceBinding(
@ -927,6 +1002,10 @@ void main() {
.lastArtifactSyncStatus,
'no-artifacts',
);
final snapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: 'session-1',
);
expect(snapshot.fileEntries, isEmpty);
});
test('skips download URL artifacts outside the bridge host', () async {