merge: consolidate CI and thread binding branch work
This commit is contained in:
parent
31ad01646c
commit
06117e9f0c
25
.github/workflows/build-and-release.yml
vendored
25
.github/workflows/build-and-release.yml
vendored
@ -4,13 +4,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release/**"
|
||||
tags:
|
||||
- "v*"
|
||||
paths-ignore:
|
||||
- "README.md"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "lib/**"
|
||||
- "assets/**"
|
||||
@ -39,6 +38,7 @@ env:
|
||||
|
||||
jobs:
|
||||
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
|
||||
needs:
|
||||
- verify
|
||||
@ -85,11 +85,28 @@ jobs:
|
||||
shell: bash
|
||||
run: bash ./scripts/ci/setup_platform_deps.sh linux
|
||||
|
||||
- name: Run analysis and tests
|
||||
- name: Run Flutter verification suite
|
||||
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:
|
||||
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 }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
33
.github/workflows/testing.yml
vendored
33
.github/workflows/testing.yml
vendored
@ -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
|
||||
@ -45,6 +45,41 @@ import 'app_controller_desktop_thread_storage.dart';
|
||||
import 'app_controller_desktop_skill_permissions.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 {
|
||||
String managedLocalThreadWorkspaceSuffixInternal(String sessionKey) =>
|
||||
'/.xworkmate/threads/${threadWorkspaceDirectoryNameInternal(sessionKey)}';
|
||||
@ -161,20 +196,6 @@ extension AppControllerDesktopThreadBinding on AppController {
|
||||
required ThreadOwnerScope ownerScope,
|
||||
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 (existingBinding != null &&
|
||||
existingBinding.workspaceKind == WorkspaceKind.localFs &&
|
||||
@ -264,39 +285,34 @@ extension AppControllerDesktopThreadBinding on AppController {
|
||||
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
|
||||
sessionKey,
|
||||
);
|
||||
final existing = assistantThreadRecordsInternal[normalizedSessionKey];
|
||||
final resolvedExecutionTarget =
|
||||
executionTarget ??
|
||||
(existing == null
|
||||
? null
|
||||
: assistantExecutionTargetFromExecutionMode(
|
||||
existing.executionBinding.executionMode,
|
||||
)) ??
|
||||
assistantExecutionTargetForSession(normalizedSessionKey);
|
||||
final ownerScope = await ensureDesktopThreadOwnerScopeInternal(
|
||||
normalizedSessionKey,
|
||||
);
|
||||
final latestRecord = assistantThreadRecordsInternal[normalizedSessionKey];
|
||||
final snapshot = resolveDesktopThreadBindingSnapshotInternal(
|
||||
defaultExecutionTarget: settings.assistantExecutionTarget,
|
||||
executionTargetOverride: executionTarget,
|
||||
latestRecord: latestRecord,
|
||||
);
|
||||
final workspaceBinding = buildDesktopWorkspaceBindingInternal(
|
||||
normalizedSessionKey,
|
||||
executionTarget: resolvedExecutionTarget,
|
||||
executionTarget: snapshot.executionTarget,
|
||||
ownerScope: ownerScope,
|
||||
existingBinding: existing?.workspaceBinding,
|
||||
existingBinding: snapshot.record?.workspaceBinding,
|
||||
);
|
||||
upsertTaskThreadInternal(
|
||||
normalizedSessionKey,
|
||||
ownerScope: ownerScope,
|
||||
workspaceBinding: workspaceBinding,
|
||||
executionBinding: buildDesktopExecutionBindingInternal(
|
||||
executionTarget: resolvedExecutionTarget,
|
||||
executionTarget: snapshot.executionTarget,
|
||||
singleAgentProvider: settings.sanitizeSingleAgentProviderSelection(
|
||||
SingleAgentProviderCopy.fromJsonValue(
|
||||
existing?.executionBinding.providerId ?? '',
|
||||
),
|
||||
snapshot.singleAgentProvider,
|
||||
),
|
||||
existingBinding: existing?.executionBinding,
|
||||
existingBinding: snapshot.record?.executionBinding,
|
||||
),
|
||||
lifecycleState:
|
||||
(existing?.lifecycleState ??
|
||||
(snapshot.record?.lifecycleState ??
|
||||
const ThreadLifecycleState(
|
||||
archived: false,
|
||||
status: 'ready',
|
||||
|
||||
@ -47,6 +47,45 @@ import 'app_controller_desktop_runtime_helpers.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
|
||||
|
||||
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 {
|
||||
TaskThread? taskThreadForSessionInternal(String sessionKey) {
|
||||
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
|
||||
@ -394,41 +433,10 @@ extension AppControllerDesktopThreadSessions on AppController {
|
||||
);
|
||||
}
|
||||
|
||||
final expectedMode = target == AssistantExecutionTarget.local
|
||||
? RuntimeConnectionMode.local
|
||||
: RuntimeConnectionMode.remote;
|
||||
final matchesTarget = connection.mode == expectedMode;
|
||||
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,
|
||||
return resolveGatewayThreadConnectionStateInternal(
|
||||
target: target,
|
||||
connection: connection,
|
||||
targetProfile: gatewayProfileForAssistantExecutionTargetInternal(target),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -256,6 +256,9 @@ class MobileShellStateInternal extends State<MobileShell> {
|
||||
?.trim()
|
||||
.isNotEmpty ??
|
||||
false;
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (!accountSignedIn) {
|
||||
await openGatewaySetupCodeEntryInternal();
|
||||
messenger?.showSnackBar(
|
||||
|
||||
@ -2,7 +2,7 @@ name: xworkmate
|
||||
description: "XWorkmate desktop-first AI workspace shell."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.1.0+4
|
||||
version: 1.0.0-beta.2+4
|
||||
build-date: 2026-03-28
|
||||
build-id: f153d7b
|
||||
|
||||
|
||||
8
scripts/ci/run_flutter_ci_suite.sh
Normal file
8
scripts/ci/run_flutter_ci_suite.sh
Normal 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
|
||||
6
scripts/ci/run_patrol_suite.sh
Normal file
6
scripts/ci/run_patrol_suite.sh
Normal file
@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
flutter pub get
|
||||
dart pub global activate patrol_cli
|
||||
patrol test
|
||||
133
test/app_controller_desktop_thread_binding_test.dart
Normal file
133
test/app_controller_desktop_thread_binding_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user