merge: consolidate CI and thread binding branch work

This commit is contained in:
Haitao Pan 2026-04-11 08:20:51 +08:00
parent 31ad01646c
commit 06117e9f0c
9 changed files with 262 additions and 104 deletions

View File

@ -4,13 +4,12 @@ on:
push: push:
branches: branches:
- main - main
- "release/**"
tags: tags:
- "v*" - "v*"
paths-ignore: paths-ignore:
- "README.md" - "README.md"
pull_request: pull_request:
branches:
- main
paths: paths:
- "lib/**" - "lib/**"
- "assets/**" - "assets/**"
@ -39,6 +38,7 @@ env:
jobs: jobs:
prepare: prepare:
if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.base_ref == 'main') }}
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: needs:
- verify - verify
@ -85,11 +85,28 @@ jobs:
shell: bash shell: bash
run: bash ./scripts/ci/setup_platform_deps.sh linux run: bash ./scripts/ci/setup_platform_deps.sh linux
- name: Run analysis and tests - name: Run Flutter verification suite
shell: bash shell: bash
run: bash ./scripts/ci/run_code_analysis.sh run: bash ./scripts/ci/run_flutter_ci_suite.sh
patrol:
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/heads/release/') }}
runs-on: ubuntu-22.04
steps:
- name: Checkout source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Set up Flutter SDK
uses: ./.github/actions/setup-flutter-sdk
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Run Patrol suite
shell: bash
run: bash ./scripts/ci/run_patrol_suite.sh
build: build:
if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.base_ref == 'main') }}
name: Build ${{ matrix.platform }} ${{ matrix.package }} name: Build ${{ matrix.platform }} ${{ matrix.package }}
strategy: strategy:
fail-fast: false fail-fast: false

View File

@ -1,33 +0,0 @@
name: testing
on:
pull_request:
push:
branches:
- main
- 'release/**'
jobs:
flutter:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter pub get
- run: flutter test
- run: flutter test test/golden
- run: flutter test integration_test
patrol:
if: startsWith(github.ref, 'refs/heads/release/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter pub get
- run: dart pub global activate patrol_cli
- run: patrol test

View File

@ -45,6 +45,41 @@ import 'app_controller_desktop_thread_storage.dart';
import 'app_controller_desktop_skill_permissions.dart'; import 'app_controller_desktop_skill_permissions.dart';
import 'app_controller_desktop_runtime_helpers.dart'; import 'app_controller_desktop_runtime_helpers.dart';
class DesktopThreadBindingSnapshotInternal {
const DesktopThreadBindingSnapshotInternal({
required this.executionTarget,
required this.singleAgentProvider,
required this.record,
});
final AssistantExecutionTarget executionTarget;
final SingleAgentProvider singleAgentProvider;
final TaskThread? record;
}
DesktopThreadBindingSnapshotInternal
resolveDesktopThreadBindingSnapshotInternal({
required AssistantExecutionTarget defaultExecutionTarget,
AssistantExecutionTarget? executionTargetOverride,
TaskThread? latestRecord,
}) {
final resolvedExecutionTarget =
executionTargetOverride ??
(latestRecord == null
? defaultExecutionTarget
: assistantExecutionTargetFromExecutionMode(
latestRecord.executionBinding.executionMode,
));
final resolvedProvider = SingleAgentProviderCopy.fromJsonValue(
latestRecord?.executionBinding.providerId ?? '',
);
return DesktopThreadBindingSnapshotInternal(
executionTarget: resolvedExecutionTarget,
singleAgentProvider: resolvedProvider,
record: latestRecord,
);
}
extension AppControllerDesktopThreadBinding on AppController { extension AppControllerDesktopThreadBinding on AppController {
String managedLocalThreadWorkspaceSuffixInternal(String sessionKey) => String managedLocalThreadWorkspaceSuffixInternal(String sessionKey) =>
'/.xworkmate/threads/${threadWorkspaceDirectoryNameInternal(sessionKey)}'; '/.xworkmate/threads/${threadWorkspaceDirectoryNameInternal(sessionKey)}';
@ -161,20 +196,6 @@ extension AppControllerDesktopThreadBinding on AppController {
required ThreadOwnerScope ownerScope, required ThreadOwnerScope ownerScope,
WorkspaceBinding? existingBinding, WorkspaceBinding? existingBinding,
}) { }) {
final preservesRemoteSingleAgentBinding =
existingBinding != null &&
existingBinding.workspaceKind == WorkspaceKind.remoteFs &&
existingBinding.workspacePath.trim().isNotEmpty &&
!isOwnerScopedRemoteWorkspacePathInternal(
existingBinding.workspacePath,
);
if (preservesRemoteSingleAgentBinding) {
return existingBinding.copyWith(
displayPath: existingBinding.displayPath.trim().isEmpty
? existingBinding.workspacePath
: null,
);
}
if (executionTarget == AssistantExecutionTarget.singleAgent) { if (executionTarget == AssistantExecutionTarget.singleAgent) {
if (existingBinding != null && if (existingBinding != null &&
existingBinding.workspaceKind == WorkspaceKind.localFs && existingBinding.workspaceKind == WorkspaceKind.localFs &&
@ -264,39 +285,34 @@ extension AppControllerDesktopThreadBinding on AppController {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal( final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey, sessionKey,
); );
final existing = assistantThreadRecordsInternal[normalizedSessionKey];
final resolvedExecutionTarget =
executionTarget ??
(existing == null
? null
: assistantExecutionTargetFromExecutionMode(
existing.executionBinding.executionMode,
)) ??
assistantExecutionTargetForSession(normalizedSessionKey);
final ownerScope = await ensureDesktopThreadOwnerScopeInternal( final ownerScope = await ensureDesktopThreadOwnerScopeInternal(
normalizedSessionKey, normalizedSessionKey,
); );
final latestRecord = assistantThreadRecordsInternal[normalizedSessionKey];
final snapshot = resolveDesktopThreadBindingSnapshotInternal(
defaultExecutionTarget: settings.assistantExecutionTarget,
executionTargetOverride: executionTarget,
latestRecord: latestRecord,
);
final workspaceBinding = buildDesktopWorkspaceBindingInternal( final workspaceBinding = buildDesktopWorkspaceBindingInternal(
normalizedSessionKey, normalizedSessionKey,
executionTarget: resolvedExecutionTarget, executionTarget: snapshot.executionTarget,
ownerScope: ownerScope, ownerScope: ownerScope,
existingBinding: existing?.workspaceBinding, existingBinding: snapshot.record?.workspaceBinding,
); );
upsertTaskThreadInternal( upsertTaskThreadInternal(
normalizedSessionKey, normalizedSessionKey,
ownerScope: ownerScope, ownerScope: ownerScope,
workspaceBinding: workspaceBinding, workspaceBinding: workspaceBinding,
executionBinding: buildDesktopExecutionBindingInternal( executionBinding: buildDesktopExecutionBindingInternal(
executionTarget: resolvedExecutionTarget, executionTarget: snapshot.executionTarget,
singleAgentProvider: settings.sanitizeSingleAgentProviderSelection( singleAgentProvider: settings.sanitizeSingleAgentProviderSelection(
SingleAgentProviderCopy.fromJsonValue( snapshot.singleAgentProvider,
existing?.executionBinding.providerId ?? '',
),
), ),
existingBinding: existing?.executionBinding, existingBinding: snapshot.record?.executionBinding,
), ),
lifecycleState: lifecycleState:
(existing?.lifecycleState ?? (snapshot.record?.lifecycleState ??
const ThreadLifecycleState( const ThreadLifecycleState(
archived: false, archived: false,
status: 'ready', status: 'ready',

View File

@ -47,6 +47,45 @@ import 'app_controller_desktop_runtime_helpers.dart';
import 'app_controller_desktop_thread_sessions_collaboration_impl.dart'; import 'app_controller_desktop_thread_sessions_collaboration_impl.dart';
// ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member // ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
AssistantThreadConnectionState resolveGatewayThreadConnectionStateInternal({
required AssistantExecutionTarget target,
required GatewayConnectionSnapshot connection,
required GatewayConnectionProfile targetProfile,
}) {
final expectedMode = target == AssistantExecutionTarget.local
? RuntimeConnectionMode.local
: RuntimeConnectionMode.remote;
final matchesTarget = connection.mode == expectedMode;
final targetAddress =
targetProfile.host.trim().isNotEmpty && targetProfile.port > 0
? '${targetProfile.host.trim()}:${targetProfile.port}'
: appText('未连接目标', 'No target');
final rawStatus = matchesTarget
? connection.status
: RuntimeConnectionStatus.offline;
final pairingRequired = matchesTarget && connection.pairingRequired;
final gatewayTokenMissing = matchesTarget && connection.gatewayTokenMissing;
final status = pairingRequired || gatewayTokenMissing
? RuntimeConnectionStatus.error
: rawStatus;
final primaryLabel = pairingRequired
? appText('需配对', 'Pairing Required')
: gatewayTokenMissing
? appText('缺少令牌', 'Missing Token')
: status.label;
return AssistantThreadConnectionState(
executionTarget: target,
status: status,
primaryLabel: primaryLabel,
detailLabel: targetAddress,
ready: status == RuntimeConnectionStatus.connected,
pairingRequired: pairingRequired,
gatewayTokenMissing: gatewayTokenMissing,
lastError: matchesTarget ? connection.lastError?.trim() : null,
);
}
extension AppControllerDesktopThreadSessions on AppController { extension AppControllerDesktopThreadSessions on AppController {
TaskThread? taskThreadForSessionInternal(String sessionKey) { TaskThread? taskThreadForSessionInternal(String sessionKey) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal( final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
@ -394,41 +433,10 @@ extension AppControllerDesktopThreadSessions on AppController {
); );
} }
final expectedMode = target == AssistantExecutionTarget.local return resolveGatewayThreadConnectionStateInternal(
? RuntimeConnectionMode.local target: target,
: RuntimeConnectionMode.remote; connection: connection,
final matchesTarget = connection.mode == expectedMode; targetProfile: gatewayProfileForAssistantExecutionTargetInternal(target),
final fallbackProfile = gatewayProfileForAssistantExecutionTargetInternal(
target,
);
final fallbackAddress = gatewayAddressLabelInternal(fallbackProfile);
final detail = matchesTarget
? (connection.remoteAddress?.trim().isNotEmpty == true
? connection.remoteAddress!.trim()
: fallbackAddress)
: fallbackAddress;
final rawStatus = matchesTarget
? connection.status
: RuntimeConnectionStatus.offline;
final pairingRequired = matchesTarget && connection.pairingRequired;
final gatewayTokenMissing = matchesTarget && connection.gatewayTokenMissing;
final status = pairingRequired || gatewayTokenMissing
? RuntimeConnectionStatus.error
: rawStatus;
final primaryLabel = pairingRequired
? appText('需配对', 'Pairing Required')
: gatewayTokenMissing
? appText('缺少令牌', 'Missing Token')
: status.label;
return AssistantThreadConnectionState(
executionTarget: target,
status: status,
primaryLabel: primaryLabel,
detailLabel: detail,
ready: status == RuntimeConnectionStatus.connected,
pairingRequired: pairingRequired,
gatewayTokenMissing: gatewayTokenMissing,
lastError: matchesTarget ? connection.lastError?.trim() : null,
); );
} }

View File

@ -256,6 +256,9 @@ class MobileShellStateInternal extends State<MobileShell> {
?.trim() ?.trim()
.isNotEmpty ?? .isNotEmpty ??
false; false;
if (!mounted) {
return;
}
if (!accountSignedIn) { if (!accountSignedIn) {
await openGatewaySetupCodeEntryInternal(); await openGatewaySetupCodeEntryInternal();
messenger?.showSnackBar( messenger?.showSnackBar(

View File

@ -2,7 +2,7 @@ name: xworkmate
description: "XWorkmate desktop-first AI workspace shell." description: "XWorkmate desktop-first AI workspace shell."
publish_to: 'none' publish_to: 'none'
version: 1.1.0+4 version: 1.0.0-beta.2+4
build-date: 2026-03-28 build-date: 2026-03-28
build-id: f153d7b build-id: f153d7b

View File

@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
flutter pub get
flutter analyze
flutter test
flutter test test/golden
flutter test integration_test

View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
flutter pub get
dart pub global activate patrol_cli
patrol test

View File

@ -0,0 +1,133 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller_desktop_thread_binding.dart';
import 'package:xworkmate/app/app_controller_desktop_thread_sessions.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
void main() {
group('resolveDesktopThreadBindingSnapshotInternal', () {
const localOwner = ThreadOwnerScope(
realm: ThreadRealm.local,
subjectType: ThreadSubjectType.user,
subjectId: 'u1',
displayName: 'User',
);
TaskThread buildThread({
required String threadId,
required ThreadExecutionMode mode,
required String providerId,
}) {
return TaskThread(
threadId: threadId,
ownerScope: localOwner,
workspaceBinding: const WorkspaceBinding(
workspaceId: 'ws-1',
workspaceKind: WorkspaceKind.localFs,
workspacePath: '/tmp/ws',
displayPath: '/tmp/ws',
writable: true,
),
executionBinding: ExecutionBinding(
executionMode: mode,
executorId: providerId,
providerId: providerId,
endpointId: '',
),
);
}
test('prefers the latest record after async binding resumes', () {
final latestRecord = buildThread(
threadId: 'thread-1',
mode: ThreadExecutionMode.localAgent,
providerId: SingleAgentProvider.opencode.providerId,
);
final snapshot = resolveDesktopThreadBindingSnapshotInternal(
defaultExecutionTarget: AssistantExecutionTarget.local,
latestRecord: latestRecord,
);
expect(snapshot.executionTarget, AssistantExecutionTarget.singleAgent);
expect(snapshot.singleAgentProvider, SingleAgentProvider.opencode);
expect(snapshot.record, same(latestRecord));
});
test(
'keeps an explicit execution override while preserving latest provider',
() {
final latestRecord = buildThread(
threadId: 'thread-2',
mode: ThreadExecutionMode.localAgent,
providerId: SingleAgentProvider.opencode.providerId,
);
final snapshot = resolveDesktopThreadBindingSnapshotInternal(
defaultExecutionTarget: AssistantExecutionTarget.local,
executionTargetOverride: AssistantExecutionTarget.remote,
latestRecord: latestRecord,
);
expect(snapshot.executionTarget, AssistantExecutionTarget.remote);
expect(snapshot.singleAgentProvider, SingleAgentProvider.opencode);
},
);
test('does not recover provider from stale fallback-only records', () {
final staleRecord = buildThread(
threadId: 'thread-3',
mode: ThreadExecutionMode.gatewayRemote,
providerId: SingleAgentProvider.codex.providerId,
);
final snapshot = resolveDesktopThreadBindingSnapshotInternal(
defaultExecutionTarget: AssistantExecutionTarget.remote,
latestRecord: null,
);
expect(snapshot.executionTarget, AssistantExecutionTarget.remote);
expect(snapshot.singleAgentProvider, SingleAgentProvider.auto);
expect(snapshot.record, isNull);
expect(staleRecord.executionBinding.providerId, isNotEmpty);
});
});
group('resolveGatewayThreadConnectionStateInternal', () {
test('uses the thread target profile as the only address source', () {
final state = resolveGatewayThreadConnectionStateInternal(
target: AssistantExecutionTarget.remote,
connection:
GatewayConnectionSnapshot.initial(
mode: RuntimeConnectionMode.remote,
).copyWith(
status: RuntimeConnectionStatus.connected,
remoteAddress: '127.0.0.1:18789',
),
targetProfile: GatewayConnectionProfile.defaultsRemote(),
);
expect(state.status, RuntimeConnectionStatus.connected);
expect(state.detailLabel, 'openclaw.svc.plus:443');
expect(state.ready, isTrue);
});
test('marks mismatched local snapshot as offline for remote threads', () {
final state = resolveGatewayThreadConnectionStateInternal(
target: AssistantExecutionTarget.remote,
connection:
GatewayConnectionSnapshot.initial(
mode: RuntimeConnectionMode.local,
).copyWith(
status: RuntimeConnectionStatus.connected,
remoteAddress: '127.0.0.1:18789',
),
targetProfile: GatewayConnectionProfile.defaultsRemote(),
);
expect(state.status, RuntimeConnectionStatus.offline);
expect(state.detailLabel, 'openclaw.svc.plus:443');
expect(state.ready, isFalse);
expect(state.lastError, isNull);
});
});
}