xworkmate-app/lib/app/app_controller_desktop_skill_permissions.dart

478 lines
18 KiB
Dart

// ignore_for_file: unused_import, unnecessary_import
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'app_metadata.dart';
import 'app_capabilities.dart';
import 'app_store_policy.dart';
import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';
import '../runtime/runtime_controllers.dart';
import '../runtime/runtime_models.dart';
import '../runtime/secure_config_store.dart';
import '../runtime/embedded_agent_launch_policy.dart';
import '../runtime/runtime_coordinator.dart';
import '../runtime/gateway_acp_client.dart';
import '../runtime/codex_runtime.dart';
import '../runtime/codex_config_bridge.dart';
import '../runtime/code_agent_node_orchestrator.dart';
import '../runtime/assistant_artifacts.dart';
import '../runtime/desktop_thread_artifact_service.dart';
import '../runtime/mode_switcher.dart';
import '../runtime/agent_registry.dart';
import '../runtime/multi_agent_orchestrator.dart';
import '../runtime/platform_environment.dart';
import '../runtime/skill_directory_access.dart';
import 'app_controller_desktop_core.dart';
import 'app_controller_desktop_navigation.dart';
import 'app_controller_desktop_gateway.dart';
import 'app_controller_desktop_settings.dart';
import 'app_controller_desktop_single_agent.dart';
import 'app_controller_desktop_thread_sessions.dart';
import 'app_controller_desktop_thread_actions.dart';
import 'app_controller_desktop_thread_binding.dart';
import 'app_controller_desktop_workspace_execution.dart';
import 'app_controller_desktop_settings_runtime.dart';
import 'app_controller_desktop_thread_storage.dart';
import 'app_controller_desktop_runtime_helpers.dart';
// ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
extension AppControllerDesktopSkillPermissions on AppController {
Future<void> refreshSharedSingleAgentLocalSkillsCacheInternal({
required bool forceRescan,
}) async {
if (!forceRescan && singleAgentLocalSkillsHydratedInternal) {
return;
}
if (!forceRescan &&
await restoreSharedSingleAgentLocalSkillsCacheInternal()) {
return;
}
final existingRefresh = singleAgentSharedSkillsRefreshInFlightInternal;
if (existingRefresh != null) {
await existingRefresh;
if (!forceRescan) {
return;
}
}
late final Future<void> refreshFuture;
refreshFuture = () async {
final sharedSkills = await scanSingleAgentSharedSkillEntriesInternal();
singleAgentSharedImportedSkillsInternal = sharedSkills;
singleAgentLocalSkillsHydratedInternal = true;
await persistSharedSingleAgentLocalSkillsCacheInternal();
}();
singleAgentSharedSkillsRefreshInFlightInternal = refreshFuture;
try {
await refreshFuture;
} finally {
if (identical(
singleAgentSharedSkillsRefreshInFlightInternal,
refreshFuture,
)) {
singleAgentSharedSkillsRefreshInFlightInternal = null;
}
}
}
Future<void> ensureSharedSingleAgentLocalSkillsLoaded() async {
if (singleAgentLocalSkillsHydratedInternal) {
return;
}
await refreshSharedSingleAgentLocalSkillsCacheInternal(forceRescan: false);
}
Future<void> startupRefreshSharedSingleAgentLocalSkillsCacheInternal() async {
await refreshSharedSingleAgentLocalSkillsCacheInternal(forceRescan: true);
if (disposedInternal) {
return;
}
if (assistantExecutionTargetForSession(currentSessionKey) ==
AssistantExecutionTarget.singleAgent) {
await refreshSingleAgentSkillsForSession(currentSessionKey);
return;
}
notifyIfActiveInternal();
}
Future<List<AssistantThreadSkillEntry>>
singleAgentLocalSkillsForSessionInternal(String sessionKey) async {
await ensureSharedSingleAgentLocalSkillsLoaded();
final workspaceSkills = await scanSingleAgentWorkspaceSkillEntriesInternal(
sessionKey,
);
return mergeSingleAgentSkillEntriesInternal(
groups: <List<AssistantThreadSkillEntry>>[
singleAgentSharedImportedSkillsInternal,
workspaceSkills,
],
);
}
List<AssistantThreadSkillEntry> mergeSingleAgentSkillEntriesInternal({
required List<List<AssistantThreadSkillEntry>> groups,
}) {
final merged = <String, AssistantThreadSkillEntry>{};
for (final group in groups) {
for (final skill in group) {
final normalizedName = skill.label.trim().toLowerCase();
if (normalizedName.isEmpty || merged.containsKey(normalizedName)) {
continue;
}
merged[normalizedName] = skill;
}
}
final entries = merged.values.toList(growable: false);
entries.sort((left, right) => left.label.compareTo(right.label));
return entries;
}
Future<bool> restoreSharedSingleAgentLocalSkillsCacheInternal() async {
try {
final payload = await storeInternal.loadSupportJson(
singleAgentLocalSkillsCacheRelativePathInternal,
);
if (payload == null) {
return false;
}
final schemaVersion = int.tryParse(
payload['schemaVersion']?.toString() ?? '',
);
if (schemaVersion != singleAgentLocalSkillsCacheSchemaVersionInternal) {
return false;
}
final skills = asList(payload['skills'])
.map(asMap)
.map(
(item) => AssistantThreadSkillEntry.fromJson(
item.cast<String, dynamic>(),
),
)
.where((item) => item.key.trim().isNotEmpty && item.label.isNotEmpty)
.toList(growable: false);
if (skills.isEmpty) {
singleAgentSharedImportedSkillsInternal =
const <AssistantThreadSkillEntry>[];
singleAgentLocalSkillsHydratedInternal = false;
return false;
}
singleAgentSharedImportedSkillsInternal = skills;
singleAgentLocalSkillsHydratedInternal = true;
return true;
} catch (_) {
return false;
}
}
Future<void> persistSharedSingleAgentLocalSkillsCacheInternal() async {
try {
await storeInternal.saveSupportJson(
singleAgentLocalSkillsCacheRelativePathInternal,
<String, dynamic>{
'schemaVersion': singleAgentLocalSkillsCacheSchemaVersionInternal,
'savedAtMs': DateTime.now().millisecondsSinceEpoch.toDouble(),
'skills': singleAgentSharedImportedSkillsInternal
.map((item) => item.toJson())
.toList(growable: false),
},
);
} catch (_) {
// Best effort only for local cache persistence.
}
}
Future<void> replaceSingleAgentThreadSkillsInternal(
String sessionKey,
List<AssistantThreadSkillEntry> importedSkills,
) async {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
final importedKeys = importedSkills.map((item) => item.key).toSet();
final nextSelected =
(assistantThreadRecordsInternal[normalizedSessionKey]
?.selectedSkillKeys ??
const <String>[])
.where(importedKeys.contains)
.toList(growable: false);
upsertTaskThreadInternal(
normalizedSessionKey,
importedSkills: importedSkills,
selectedSkillKeys: nextSelected,
selectedSkillsSource: assistantThreadRecordsInternal[normalizedSessionKey]
?.contextState
.selectedSkillsSource,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
notifyIfActiveInternal();
}
AssistantThreadSkillEntry singleAgentSkillEntryFromAcpInternal(
Map<String, dynamic> item,
SingleAgentProvider provider,
) {
return AssistantThreadSkillEntry(
key: item['skillKey']?.toString().trim().isNotEmpty == true
? item['skillKey'].toString().trim()
: (item['name']?.toString().trim() ?? ''),
label: item['name']?.toString().trim() ?? '',
description: item['description']?.toString().trim() ?? '',
source: item['source']?.toString().trim() ?? provider.providerId,
sourcePath: item['path']?.toString().trim() ?? '',
scope: item['scope']?.toString().trim().isNotEmpty == true
? item['scope'].toString().trim()
: 'session',
sourceLabel: item['sourceLabel']?.toString().trim().isNotEmpty == true
? item['sourceLabel'].toString().trim()
: (item['source']?.toString().trim().isNotEmpty == true
? item['source'].toString().trim()
: provider.label),
);
}
bool unsupportedAcpSkillsStatusInternal(GatewayAcpException error) {
final code = (error.code ?? '').trim();
if (code == '-32601' || code == 'METHOD_NOT_FOUND') {
return true;
}
final message = error.toString().toLowerCase();
return message.contains('unknown method') ||
message.contains('method not found') ||
message.contains('skills.status');
}
void upsertTaskThreadInternal(
String sessionKey, {
ThreadOwnerScope? ownerScope,
WorkspaceBinding? workspaceBinding,
ExecutionBinding? executionBinding,
ThreadContextState? contextState,
ThreadLifecycleState? lifecycleState,
List<GatewayChatMessage>? messages,
double? updatedAtMs,
String? title,
bool? archived,
AssistantExecutionTarget? executionTarget,
AssistantMessageViewMode? messageViewMode,
List<AssistantThreadSkillEntry>? importedSkills,
List<String>? selectedSkillKeys,
String? assistantModelId,
SingleAgentProvider? singleAgentProvider,
ThreadSelectionSource? executionTargetSource,
ThreadSelectionSource? singleAgentProviderSource,
ThreadSelectionSource? assistantModelSource,
ThreadSelectionSource? selectedSkillsSource,
String? gatewayEntryState,
String? latestResolvedRuntimeModel,
String? lifecycleStatus,
double? lastRunAtMs,
String? lastResultCode,
String? lastRemoteWorkingDirectory,
WorkspaceRefKind? lastRemoteWorkspaceRefKind,
double? lastArtifactSyncAtMs,
String? lastArtifactSyncStatus,
}) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
final existing = taskThreadForSessionInternal(normalizedSessionKey);
final nextExecutionTarget =
executionTarget ??
switch (existing?.executionBinding.executionMode) {
ThreadExecutionMode.localAgent =>
AssistantExecutionTarget.singleAgent,
ThreadExecutionMode.gatewayLocal => AssistantExecutionTarget.local,
ThreadExecutionMode.gatewayRemote => AssistantExecutionTarget.remote,
null => settings.assistantExecutionTarget,
};
final nextImportedSkills =
importedSkills ??
existing?.importedSkills ??
const <AssistantThreadSkillEntry>[];
final importedKeys = nextImportedSkills.map((item) => item.key).toSet();
final nextSelectedSkillKeys =
(selectedSkillKeys ?? existing?.selectedSkillKeys ?? const <String>[])
.where(importedKeys.contains)
.toList(growable: false);
final nextMessages =
messages ??
existing?.messages ??
assistantThreadMessagesInternal[normalizedSessionKey] ??
const <GatewayChatMessage>[];
final nextOwnerScope =
ownerScope ??
existing?.ownerScope ??
const ThreadOwnerScope(
realm: ThreadRealm.local,
subjectType: ThreadSubjectType.user,
subjectId: '',
displayName: '',
);
final nextWorkspaceBinding =
workspaceBinding ??
existing?.workspaceBinding ??
(nextExecutionTarget == AssistantExecutionTarget.singleAgent
? buildDesktopWorkspaceBindingInternal(
normalizedSessionKey,
executionTarget: nextExecutionTarget,
ownerScope: nextOwnerScope,
existingBinding: null,
)
: null);
if (nextWorkspaceBinding == null || !nextWorkspaceBinding.isComplete) {
throw StateError(
'TaskThread $normalizedSessionKey is missing a complete workspaceBinding.',
);
}
final nextProvider =
singleAgentProvider ??
SingleAgentProviderCopy.fromJsonValue(
executionBinding?.providerId ?? existing?.executionBinding.providerId,
);
final nextExecutionBinding =
(executionBinding ??
existing?.executionBinding ??
ExecutionBinding(
executionMode: ThreadExecutionMode.localAgent,
executorId: nextProvider.providerId,
providerId: nextProvider.providerId,
endpointId: '',
))
.copyWith(
executionMode: switch (nextExecutionTarget) {
AssistantExecutionTarget.singleAgent =>
ThreadExecutionMode.localAgent,
AssistantExecutionTarget.local =>
ThreadExecutionMode.gatewayLocal,
AssistantExecutionTarget.remote =>
ThreadExecutionMode.gatewayRemote,
},
executorId: nextProvider.providerId,
providerId: nextProvider.providerId,
executionModeSource:
executionTargetSource ??
existing?.executionBinding.executionModeSource,
providerSource:
singleAgentProviderSource ??
existing?.executionBinding.providerSource,
);
final nextContextState =
(contextState ??
existing?.contextState ??
ThreadContextState(
messages: nextMessages,
selectedModelId:
assistantModelId ??
resolvedAssistantModelForTargetInternal(
nextExecutionTarget,
),
selectedSkillKeys: const <String>[],
importedSkills: const <AssistantThreadSkillEntry>[],
permissionLevel: AssistantPermissionLevel.defaultAccess,
messageViewMode: AssistantMessageViewMode.rendered,
latestResolvedRuntimeModel: '',
gatewayEntryState: gatewayEntryStateForTargetInternal(
nextExecutionTarget,
),
lastRemoteWorkingDirectory: null,
lastRemoteWorkspaceRefKind: null,
lastArtifactSyncAtMs: null,
lastArtifactSyncStatus: null,
))
.copyWith(
messages: nextMessages,
messageViewMode: messageViewMode,
importedSkills: nextImportedSkills,
selectedSkillKeys: nextSelectedSkillKeys,
selectedModelId:
assistantModelId ??
existing?.assistantModelId ??
resolvedAssistantModelForTargetInternal(nextExecutionTarget),
selectedModelSource:
assistantModelSource ??
existing?.contextState.selectedModelSource,
selectedSkillsSource:
selectedSkillsSource ??
existing?.contextState.selectedSkillsSource,
latestResolvedRuntimeModel: latestResolvedRuntimeModel,
gatewayEntryState: gatewayEntryState,
lastRemoteWorkingDirectory: lastRemoteWorkingDirectory,
lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind,
lastArtifactSyncAtMs: lastArtifactSyncAtMs,
lastArtifactSyncStatus: lastArtifactSyncStatus,
);
final nextStatus =
lifecycleStatus ??
lifecycleState?.status ??
existing?.lifecycleState.status ??
'ready';
final nextLifecycleState =
(lifecycleState ??
existing?.lifecycleState ??
ThreadLifecycleState(
archived:
archived ??
existing?.archived ??
isAssistantTaskArchived(normalizedSessionKey),
status: nextStatus,
lastRunAtMs: null,
lastResultCode: null,
))
.copyWith(
archived:
archived ??
existing?.archived ??
isAssistantTaskArchived(normalizedSessionKey),
status: nextStatus,
lastRunAtMs: lastRunAtMs,
lastResultCode: lastResultCode,
);
final nextRecord = TaskThread(
threadId: normalizedSessionKey,
createdAtMs:
existing?.createdAtMs ??
DateTime.now().millisecondsSinceEpoch.toDouble(),
title: title ?? existing?.title ?? '',
ownerScope: nextOwnerScope,
workspaceBinding: nextWorkspaceBinding,
executionBinding: nextExecutionBinding,
contextState: nextContextState,
lifecycleState: nextLifecycleState,
updatedAtMs:
updatedAtMs ??
existing?.updatedAtMs ??
(nextMessages.isNotEmpty ? nextMessages.last.timestampMs : null),
);
taskThreadRepositoryInternal.replace(nextRecord);
if (messages != null) {
assistantThreadMessagesInternal[normalizedSessionKey] =
List<GatewayChatMessage>.from(messages);
}
}
Future<void> setCurrentAssistantSessionKeyInternal(
String sessionKey, {
bool persistSelection = true,
}) async {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
if (normalizedSessionKey.isEmpty) {
return;
}
await sessionsControllerInternal.switchSession(normalizedSessionKey);
if (persistSelection) {
await persistAssistantLastSessionKeyInternal(normalizedSessionKey);
}
}
}