diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index 5891665..356708d 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -401,6 +401,31 @@ public class BossApiClient { return requestWithRestore("PATCH", "/api/v1/devices/" + encode(deviceId), payload); } + public ApiResponse updateDevicePreferredExecutionMode( + String deviceId, + @Nullable String preferredExecutionMode + ) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put( + "preferredExecutionMode", + preferredExecutionMode == null ? JSONObject.NULL : preferredExecutionMode + ); + return updateDevice(deviceId, payload); + } + + public ApiResponse updateProjectConflictDecision( + String deviceId, + String projectId, + @Nullable String folderKey, + String decision + ) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("projectId", projectId); + payload.put("folderKey", folderKey == null ? JSONObject.NULL : folderKey); + payload.put("conflictDecision", decision); + return updateDevice(deviceId, payload); + } + public ApiResponse getDeviceSkills(String deviceId) throws IOException, JSONException { return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/skills", null); } diff --git a/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java index 44f99b9..eeb834a 100644 --- a/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java @@ -22,7 +22,7 @@ public class DeviceDetailActivity extends BossScreenActivity { super.onCreate(savedInstanceState); deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID); deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME); - configureScreen(deviceName == null ? "设备详情" : deviceName, "设备状态与绑定项目"); + configureScreen(deviceName == null ? "设备详情" : deviceName, "设备状态、GUI/CLI 能力与默认执行模式"); setHeaderAction("编辑", v -> openEditDialog()); reload(); } @@ -47,6 +47,7 @@ public class DeviceDetailActivity extends BossScreenActivity { private void renderDevice(JSONObject payload) { JSONObject workspace = payload.optJSONObject("workspace"); JSONObject device = workspace == null ? null : workspace.optJSONObject("selectedDevice"); + JSONObject primaryPolicy = resolvePrimaryProjectExecutionPolicy(workspace); replaceContent(); if (device == null) { @@ -56,7 +57,7 @@ public class DeviceDetailActivity extends BossScreenActivity { } deviceName = device.optString("name", deviceId); - configureScreen(deviceName, "设备状态与绑定项目"); + configureScreen(deviceName, "设备状态、GUI/CLI 能力与默认执行模式"); WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(device); appendContent(BossUi.buildDeviceCard( this, @@ -74,6 +75,64 @@ public class DeviceDetailActivity extends BossScreenActivity { null )); } + appendContent(BossUi.buildWechatMenuRow( + this, + WechatSurfaceMapper.deviceCapabilityTitle("gui"), + WechatSurfaceMapper.deviceCapabilityStatusLabel(device, "gui"), + WechatSurfaceMapper.deviceCapabilityDetailLabel(device, "gui"), + null, + null + )); + appendContent(BossUi.buildWechatMenuRow( + this, + WechatSurfaceMapper.deviceCapabilityTitle("cli"), + WechatSurfaceMapper.deviceCapabilityStatusLabel(device, "cli"), + WechatSurfaceMapper.deviceCapabilityDetailLabel(device, "cli"), + null, + null + )); + appendContent(BossUi.buildWechatMenuRow( + this, + "默认执行模式", + WechatSurfaceMapper.devicePreferredExecutionModeSummary(device), + "切换", + null, + v -> showPreferredExecutionModeDialog(device) + )); + if (primaryPolicy != null) { + appendContent(BossUi.buildWechatMenuRow( + this, + "异常项目 / 文件夹冲突", + primaryPolicy.optString("projectId", "未知项目"), + primaryPolicy.optString("folderKey", ""), + null, + null + )); + appendContent(BossUi.buildWechatMenuRow( + this, + "当前冲突态", + WechatSurfaceMapper.projectConflictStateLabel(primaryPolicy.optString("conflictState", "")), + null, + null, + null + )); + appendContent(BossUi.buildWechatMenuRow( + this, + "当前策略", + WechatSurfaceMapper.projectConflictAllowPolicyLabel(primaryPolicy.optString("allowPolicy", "")), + "仅作用于当前异常项目 / 文件夹", + null, + null + )); + appendContent(BossUi.buildWechatMenuRow( + this, + "冲突策略", + "禁止 / 允许本次 / 永久放行", + "切换", + null, + v -> showConflictDecisionDialog(payload) + )); + } appendContent(BossUi.buildMenuRow(this, "导入项目", "勾选这台设备上要暴露到会话首页的项目和线程", null, v -> openImportDraft())); appendContent(BossUi.buildMenuRow(this, "查看技能", "查看当前设备同步的 Skill 清单", null, v -> openSkills())); setRefreshing(false); @@ -93,6 +152,50 @@ public class DeviceDetailActivity extends BossScreenActivity { startActivity(intent); } + private void showPreferredExecutionModeDialog(JSONObject device) { + String currentMode = device == null ? "cli" : device.optString("preferredExecutionMode", "cli"); + String[] modeLabels = new String[] { + WechatSurfaceMapper.deviceExecutionModeChoiceLabel("gui"), + WechatSurfaceMapper.deviceExecutionModeChoiceLabel("cli") + }; + String[] modeValues = new String[] {"gui", "cli"}; + int checkedIndex = "gui".equals(currentMode) ? 0 : 1; + + new AlertDialog.Builder(this) + .setTitle("默认执行模式") + .setSingleChoiceItems(modeLabels, checkedIndex, (dialog, which) -> { + dialog.dismiss(); + savePreferredExecutionMode(modeValues[which]); + }) + .setNegativeButton("取消", null) + .show(); + } + + private void showConflictDecisionDialog(JSONObject payload) { + JSONObject workspace = payload == null ? null : payload.optJSONObject("workspace"); + JSONObject primaryPolicy = resolvePrimaryProjectExecutionPolicy(workspace); + if (primaryPolicy == null) { + showMessage("当前没有可处理的异常项目 / 文件夹。"); + return; + } + String[] labels = new String[] {"禁止", "允许本次", "永久放行"}; + String[] values = new String[] {"forbid", "allow_once", "allow_always"}; + int checkedIndex = resolveConflictDecisionCheckedIndex(primaryPolicy.optString("allowPolicy", "")); + + new AlertDialog.Builder(this) + .setTitle("冲突策略") + .setSingleChoiceItems(labels, checkedIndex, (dialog, which) -> { + dialog.dismiss(); + saveConflictDecision( + primaryPolicy.optString("projectId", ""), + primaryPolicy.optString("folderKey", ""), + values[which] + ); + }) + .setNegativeButton("取消", null) + .show(); + } + private void openEditDialog() { executor.execute(() -> { try { @@ -108,6 +211,52 @@ public class DeviceDetailActivity extends BossScreenActivity { }); } + private void savePreferredExecutionMode(String preferredExecutionMode) { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.updateDevicePreferredExecutionMode( + deviceId, + preferredExecutionMode + ); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage("默认执行模式已更新"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("保存失败:" + error.getMessage()); + }); + } + }); + } + + private void saveConflictDecision(String projectId, @Nullable String folderKey, String decision) { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.updateProjectConflictDecision( + deviceId, + projectId, + folderKey, + decision + ); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage("冲突策略已更新"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("保存失败:" + error.getMessage()); + }); + } + }); + } + private void showEditForm(JSONObject device) { LinearLayout form = new LinearLayout(this); form.setOrientation(LinearLayout.VERTICAL); @@ -185,4 +334,21 @@ public class DeviceDetailActivity extends BossScreenActivity { } return builder.toString(); } + + private @Nullable JSONObject resolvePrimaryProjectExecutionPolicy(@Nullable JSONObject workspace) { + if (workspace == null) return null; + JSONArray policies = workspace.optJSONArray("projectExecutionPolicies"); + if (policies == null || policies.length() == 0) return null; + return policies.optJSONObject(0); + } + + private int resolveConflictDecisionCheckedIndex(String allowPolicy) { + if ("allow_once".equals(allowPolicy)) { + return 1; + } + if ("allow_always".equals(allowPolicy)) { + return 2; + } + return 0; + } } diff --git a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java index 5a9421e..ad6b2de 100644 --- a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java +++ b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java @@ -109,6 +109,71 @@ public final class WechatSurfaceMapper { ); } + public static String deviceCapabilityTitle(String capabilityKey) { + return "gui".equals(capabilityKey) ? "GUI 能力" : "CLI 能力"; + } + + public static String deviceCapabilityStatusLabel(JSONObject device, String capabilityKey) { + JSONObject capability = resolveDeviceCapability(device, capabilityKey); + boolean connected = capability != null && capability.optBoolean("connected", false); + return connected ? "已连接" : "未连接"; + } + + public static String deviceCapabilityDetailLabel(JSONObject device, String capabilityKey) { + JSONObject capability = resolveDeviceCapability(device, capabilityKey); + if (capability == null) { + return "暂无最近上报"; + } + StringBuilder builder = new StringBuilder(); + String lastSeenAt = capability.optString("lastSeenAt", "").trim(); + String lastActiveProjectId = capability.optString("lastActiveProjectId", "").trim(); + if (!lastSeenAt.isEmpty()) { + builder.append("最近上报 ").append(lastSeenAt); + } + if (!lastActiveProjectId.isEmpty()) { + if (builder.length() > 0) { + builder.append(" · "); + } + builder.append("最近项目 ").append(lastActiveProjectId); + } + if (builder.length() == 0) { + return "暂无最近上报"; + } + return builder.toString(); + } + + public static String devicePreferredExecutionModeLabel(JSONObject device) { + return "gui".equals(device == null ? "" : device.optString("preferredExecutionMode", "")) ? "GUI" : "CLI"; + } + + public static String devicePreferredExecutionModeSummary(JSONObject device) { + return "当前默认:" + devicePreferredExecutionModeLabel(device); + } + + public static String deviceExecutionModeChoiceLabel(String mode) { + return "gui".equals(mode) ? "GUI" : "CLI"; + } + + public static String projectConflictAllowPolicyLabel(String allowPolicy) { + if ("allow_once".equals(allowPolicy)) { + return "允许本次"; + } + if ("allow_always".equals(allowPolicy)) { + return "永久放行"; + } + return "禁止"; + } + + public static String projectConflictStateLabel(String conflictState) { + if ("warning".equals(conflictState)) { + return "存在并行风险"; + } + if ("blocked".equals(conflictState)) { + return "默认阻断"; + } + return "暂无冲突"; + } + public static String[] rootTabLabels() { return ROOT_TAB_LABELS.toArray(new String[0]); } @@ -235,6 +300,17 @@ public final class WechatSurfaceMapper { return true; } + private static JSONObject resolveDeviceCapability(JSONObject device, String capabilityKey) { + if (device == null) { + return null; + } + JSONObject capabilities = device.optJSONObject("capabilities"); + if (capabilities == null) { + return null; + } + return capabilities.optJSONObject(capabilityKey); + } + public static RootTopAction rootTopAction(String activeTab, boolean refreshing) { return rootTopAction(activeTab, refreshing, false); } diff --git a/android/app/src/test/java/com/hyzq/boss/BossApiClientDeviceModeTest.java b/android/app/src/test/java/com/hyzq/boss/BossApiClientDeviceModeTest.java new file mode 100644 index 0000000..89c1d9b --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/BossApiClientDeviceModeTest.java @@ -0,0 +1,247 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import android.content.SharedPreferences; + +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.ProtocolException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class BossApiClientDeviceModeTest { + @Test + public void updateDevicePreferredExecutionModeWritesModeToPatchBody() throws Exception { + RecordingConnection connection = new RecordingConnection( + new URL("https://boss.hyzq.net/api/v1/devices/device-1") + ); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + apiClient.updateDevicePreferredExecutionMode("device-1", "gui"); + + assertEquals("/api/v1/devices/device-1", apiClient.lastPath); + assertEquals("PATCH", connection.requestMethodValue); + assertEquals("{\"preferredExecutionMode\":\"gui\"}", connection.requestBody()); + } + + @Test + public void updateProjectConflictDecisionWritesProjectScopedPatchBody() throws Exception { + RecordingConnection connection = new RecordingConnection( + new URL("https://boss.hyzq.net/api/v1/devices/device-1") + ); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + apiClient.updateProjectConflictDecision("device-1", "thread-ui", "mac-studio:boss", "allow_always"); + + assertEquals("/api/v1/devices/device-1", apiClient.lastPath); + assertEquals("PATCH", connection.requestMethodValue); + assertEquals( + "{\"projectId\":\"thread-ui\",\"folderKey\":\"mac-studio:boss\",\"conflictDecision\":\"allow_always\"}", + connection.requestBody() + ); + } + + private static final class RecordingBossApiClient extends BossApiClient { + private final RecordingConnection connection; + private String lastPath = ""; + + RecordingBossApiClient(RecordingConnection connection) { + super(new InMemorySharedPreferences(), "https://boss.hyzq.net"); + this.connection = connection; + } + + @Override + HttpURLConnection openConnection(String path) { + lastPath = path; + return connection; + } + + @Override + String encode(String value) { + return value; + } + + @Override + void rememberIdentity(JSONObject json) { + // JVM 单测只关心 patch body。 + } + } + + private static final class RecordingConnection extends HttpURLConnection { + private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream(); + private final Map requestHeaders = new HashMap<>(); + private String requestMethodValue = "GET"; + + RecordingConnection(URL url) { + super(url); + } + + @Override + public void disconnect() {} + + @Override + public boolean usingProxy() { + return false; + } + + @Override + public void connect() {} + + @Override + public void setRequestMethod(String method) throws ProtocolException { + requestMethodValue = method; + } + + @Override + public void setRequestProperty(String key, String value) { + requestHeaders.put(key, value); + } + + @Override + public String getRequestProperty(String key) { + return requestHeaders.get(key); + } + + @Override + public OutputStream getOutputStream() { + return requestBody; + } + + @Override + public int getResponseCode() { + return 200; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8)); + } + + String requestBody() { + return requestBody.toString(StandardCharsets.UTF_8); + } + } + + private static final class InMemorySharedPreferences implements SharedPreferences { + private final Map values = new HashMap<>(); + + @Override + public Map getAll() { + return Collections.unmodifiableMap(values); + } + + @Override + public String getString(String key, String defValue) { + return values.getOrDefault(key, defValue); + } + + @Override + public Set getStringSet(String key, Set defValues) { + throw new UnsupportedOperationException(); + } + + @Override + public int getInt(String key, int defValue) { + throw new UnsupportedOperationException(); + } + + @Override + public long getLong(String key, long defValue) { + throw new UnsupportedOperationException(); + } + + @Override + public float getFloat(String key, float defValue) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean contains(String key) { + return values.containsKey(key); + } + + @Override + public Editor edit() { + return new Editor() { + @Override + public Editor putString(String key, String value) { + values.put(key, value); + return this; + } + + @Override + public Editor remove(String key) { + values.remove(key); + return this; + } + + @Override + public Editor clear() { + values.clear(); + return this; + } + + @Override + public void apply() {} + + @Override + public boolean commit() { + return true; + } + + @Override + public Editor putStringSet(String key, Set values) { + throw new UnsupportedOperationException(); + } + + @Override + public Editor putInt(String key, int value) { + throw new UnsupportedOperationException(); + } + + @Override + public Editor putLong(String key, long value) { + throw new UnsupportedOperationException(); + } + + @Override + public Editor putFloat(String key, float value) { + throw new UnsupportedOperationException(); + } + + @Override + public Editor putBoolean(String key, boolean value) { + throw new UnsupportedOperationException(); + } + }; + } + + @Override + public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {} + + @Override + public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {} + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java b/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java index ea04c1a..717263f 100644 --- a/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java @@ -1,6 +1,9 @@ package com.hyzq.boss; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import android.content.Context; import android.content.Intent; @@ -8,6 +11,8 @@ import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import androidx.appcompat.app.AlertDialog; + import org.json.JSONArray; import org.json.JSONObject; import org.junit.Test; @@ -15,8 +20,14 @@ import org.junit.runner.RunWith; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowDialog; import org.robolectric.util.ReflectionHelpers; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.TimeUnit; + @RunWith(RobolectricTestRunner.class) @Config(sdk = 34) public class DeviceDetailActivityTest { @@ -37,6 +48,144 @@ public class DeviceDetailActivityTest { assertFalse(viewTreeContainsText(content, "让主 Agent 主动询问这台设备上的活跃项目目标、进度和架构")); } + @Test + public void renderDeviceShowsGuiCliCapabilitiesAndPreferredExecutionMode() throws Exception { + TestDeviceDetailActivity activity = Robolectric + .buildActivity( + TestDeviceDetailActivity.class, + new Intent() + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1") + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio") + ) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderDevice", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDevicePayload()) + ); + + View content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "GUI 能力")); + assertTrue(viewTreeContainsText(content, "CLI 能力")); + assertTrue(viewTreeContainsText(content, "默认执行模式")); + assertTrue(viewTreeContainsText(content, "当前默认:GUI")); + assertTrue(viewTreeContainsText(content, "已连接")); + assertTrue(viewTreeContainsText(content, "未连接")); + } + + @Test + public void renderDeviceShowsProjectScopedConflictCardAndActions() throws Exception { + TestDeviceDetailActivity activity = Robolectric + .buildActivity( + TestDeviceDetailActivity.class, + new Intent() + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1") + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio") + ) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderDevice", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildConflictPayload()) + ); + + View content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "异常项目 / 文件夹冲突")); + assertTrue(viewTreeContainsText(content, "thread-ui")); + assertTrue(viewTreeContainsText(content, "允许本次")); + assertTrue(viewTreeContainsText(content, "存在并行风险")); + assertTrue(viewTreeContainsText(content, "仅作用于当前异常项目 / 文件夹")); + assertTrue(viewTreeContainsText(content, "冲突策略")); + } + + @Test + public void preferredExecutionModeDialogPersistsSelectedMode() throws Exception { + TestDeviceDetailActivity activity = Robolectric + .buildActivity( + TestDeviceDetailActivity.class, + new Intent() + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1") + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio") + ) + .setup() + .get(); + + RecordingBossApiClient apiClient = new RecordingBossApiClient( + activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE), + "https://boss.hyzq.net" + ); + ReflectionHelpers.setField(activity, "apiClient", apiClient); + ReflectionHelpers.setField(activity, "executor", new DirectExecutorService()); + + ReflectionHelpers.callInstanceMethod( + activity, + "showPreferredExecutionModeDialog", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDevicePayload()) + ); + + android.app.Dialog latestDialog = ShadowDialog.getLatestDialog(); + assertTrue(latestDialog instanceof AlertDialog); + AlertDialog dialog = (AlertDialog) latestDialog; + assertNotNull(dialog.getListView()); + dialog.getListView().performItemClick( + dialog.getListView().getAdapter().getView(0, null, dialog.getListView()), + 0, + dialog.getListView().getAdapter().getItemId(0) + ); + + assertEquals(1, apiClient.updateDeviceCalls); + assertEquals("device-1", apiClient.lastDeviceId); + assertNotNull(apiClient.lastPayload); + assertEquals("gui", apiClient.lastPayload.optString("preferredExecutionMode")); + } + + @Test + public void conflictDecisionDialogPersistsScopedDecision() throws Exception { + TestDeviceDetailActivity activity = Robolectric + .buildActivity( + TestDeviceDetailActivity.class, + new Intent() + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1") + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio") + ) + .setup() + .get(); + + RecordingBossApiClient apiClient = new RecordingBossApiClient( + activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE), + "https://boss.hyzq.net" + ); + ReflectionHelpers.setField(activity, "apiClient", apiClient); + ReflectionHelpers.setField(activity, "executor", new DirectExecutorService()); + + ReflectionHelpers.callInstanceMethod( + activity, + "showConflictDecisionDialog", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildConflictPayload()) + ); + + android.app.Dialog latestDialog = ShadowDialog.getLatestDialog(); + assertTrue(latestDialog instanceof AlertDialog); + AlertDialog dialog = (AlertDialog) latestDialog; + assertNotNull(dialog.getListView()); + dialog.getListView().performItemClick( + dialog.getListView().getAdapter().getView(2, null, dialog.getListView()), + 2, + dialog.getListView().getAdapter().getItemId(2) + ); + + assertEquals(1, apiClient.updateDeviceCalls); + assertEquals("device-1", apiClient.lastDeviceId); + assertNotNull(apiClient.lastPayload); + assertEquals("thread-ui", apiClient.lastPayload.optString("projectId")); + assertEquals("mac-studio:boss", apiClient.lastPayload.optString("folderKey")); + assertEquals("allow_always", apiClient.lastPayload.optString("conflictDecision")); + } + private static boolean viewTreeContainsText(View root, String expectedText) { if (root instanceof TextView) { CharSequence text = ((TextView) root).getText(); @@ -56,6 +205,49 @@ public class DeviceDetailActivityTest { return false; } + private static JSONObject buildDevicePayload() throws Exception { + return new JSONObject() + .put("workspace", new JSONObject() + .put("selectedDevice", new JSONObject() + .put("id", "device-1") + .put("name", "Mac Studio") + .put("avatar", "M") + .put("account", "17600003315") + .put("status", "online") + .put("quota5h", 75) + .put("quota7d", 88) + .put("capabilities", new JSONObject() + .put("gui", new JSONObject() + .put("connected", true) + .put("lastSeenAt", "2026-04-06T08:50:00+08:00") + .put("lastActiveProjectId", "master-agent")) + .put("cli", new JSONObject() + .put("connected", false) + .put("lastSeenAt", "2026-04-06T08:40:00+08:00") + .put("lastActiveProjectId", ""))) + .put("preferredExecutionMode", "gui") + .put("projects", new JSONArray().put("Boss")) + .put("endpoint", "mac://studio.local") + .put("note", "测试设备"))); + } + + private static JSONObject buildConflictPayload() throws Exception { + JSONObject payload = buildDevicePayload(); + payload.getJSONObject("workspace").put( + "projectExecutionPolicies", + new JSONArray().put( + new JSONObject() + .put("deviceId", "device-1") + .put("folderKey", "mac-studio:boss") + .put("projectId", "thread-ui") + .put("allowPolicy", "allow_once") + .put("conflictState", "warning") + .put("updatedAt", "2026-04-06T12:00:00.000Z") + ) + ); + return payload; + } + public static class TestDeviceDetailActivity extends DeviceDetailActivity { @Override protected void reload() { @@ -70,21 +262,62 @@ public class DeviceDetailActivityTest { throw new RuntimeException(error); } } + } - private static JSONObject buildDevicePayload() throws Exception { - return new JSONObject() - .put("workspace", new JSONObject() - .put("selectedDevice", new JSONObject() - .put("id", "device-1") - .put("name", "Mac Studio") - .put("avatar", "M") - .put("account", "17600003315") - .put("status", "online") - .put("quota5h", 75) - .put("quota7d", 88) - .put("projects", new JSONArray().put("Boss")) - .put("endpoint", "mac://studio.local") - .put("note", "测试设备"))); + private static final class RecordingBossApiClient extends BossApiClient { + private int updateDeviceCalls; + private String lastDeviceId; + private JSONObject lastPayload; + + RecordingBossApiClient(android.content.SharedPreferences prefs, String baseUrl) { + super(prefs, baseUrl); + } + + @Override + public ApiResponse updateDevice(String deviceId, JSONObject payload) { + updateDeviceCalls += 1; + lastDeviceId = deviceId; + lastPayload = payload; + try { + return new ApiResponse(200, new JSONObject().put("ok", true)); + } catch (Exception error) { + throw new RuntimeException(error); + } + } + } + + private static final class DirectExecutorService extends AbstractExecutorService { + private boolean shutdown; + + @Override + public void shutdown() { + shutdown = true; + } + + @Override + public List shutdownNow() { + shutdown = true; + return Collections.emptyList(); + } + + @Override + public boolean isShutdown() { + return shutdown; + } + + @Override + public boolean isTerminated() { + return shutdown; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return true; + } + + @Override + public void execute(Runnable command) { + command.run(); } } } diff --git a/local-agent/config.example.json b/local-agent/config.example.json index cbeed87..afab96a 100644 --- a/local-agent/config.example.json +++ b/local-agent/config.example.json @@ -9,6 +9,8 @@ "masterAgentWorkdir": "/Users/kris/code/boss", "masterAgentSandbox": "workspace-write", "masterAgentModel": "gpt-5.4", + "preferredExecutionMode": "cli", + "guiConnected": false, "omxEnabled": false, "omxCommand": "", "omxArgs": [], diff --git a/src/app/api/v1/devices/[deviceId]/route.ts b/src/app/api/v1/devices/[deviceId]/route.ts index f7d4ae8..63a4dbc 100644 --- a/src/app/api/v1/devices/[deviceId]/route.ts +++ b/src/app/api/v1/devices/[deviceId]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; -import { updateDevice } from "@/lib/boss-data"; +import { applyProjectConflictDecision, updateDevice } from "@/lib/boss-data"; export async function PATCH( request: NextRequest, @@ -19,9 +19,33 @@ export async function PATCH( endpoint?: string; note?: string; projects?: string[]; + capabilities?: { + gui?: { + connected?: boolean; + lastSeenAt?: string; + lastActiveProjectId?: string; + }; + cli?: { + connected?: boolean; + lastSeenAt?: string; + lastActiveProjectId?: string; + }; + }; + preferredExecutionMode?: "gui" | "cli"; + projectId?: string; + folderKey?: string; + conflictDecision?: "forbid" | "allow_once" | "allow_always"; }; try { + if (body.conflictDecision && body.projectId) { + await applyProjectConflictDecision({ + deviceId, + projectId: body.projectId, + folderKey: body.folderKey, + decision: body.conflictDecision, + }); + } const device = await updateDevice(deviceId, body); return NextResponse.json({ ok: true, device }); } catch (error) { diff --git a/src/app/devices/page.tsx b/src/app/devices/page.tsx index be3594e..5eb0986 100644 --- a/src/app/devices/page.tsx +++ b/src/app/devices/page.tsx @@ -55,6 +55,7 @@ export default async function DevicesPage({ device={workspace.selectedDevice} relatedThreads={workspace.relatedThreads} activeEnrollment={workspace.activeEnrollment} + workspace={workspace} />
(device.status); + const [preferredExecutionMode, setPreferredExecutionMode] = useState< + Device["preferredExecutionMode"] + >(device.preferredExecutionMode ?? "cli"); const [endpoint, setEndpoint] = useState(device.endpoint ?? ""); const [note, setNote] = useState(device.note ?? ""); const [projects, setProjects] = useState(device.projects.join(", ")); @@ -586,6 +627,7 @@ export function DeviceEditorCard({ status, endpoint, note, + preferredExecutionMode, projects: projects .split(",") .map((item) => item.trim()) @@ -597,6 +639,25 @@ export function DeviceEditorCard({ if (result.ok) router.refresh(); } + async function saveConflictDecision(decision: "forbid" | "allow_once" | "allow_always") { + if (!primaryPolicy?.projectId) { + setMessage("当前没有可操作的异常项目 / 文件夹。"); + return; + } + const response = await fetch(`/api/v1/devices/${device.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + projectId: primaryPolicy.projectId, + folderKey: primaryPolicy.folderKey, + conflictDecision: decision, + }), + }); + const result = (await response.json()) as { ok: boolean; message?: string }; + setMessage(result.ok ? "冲突策略已更新。" : result.message ?? "更新失败"); + if (result.ok) router.refresh(); + } + return (
@@ -607,10 +668,88 @@ export function DeviceEditorCard({
+
+
{detailCards.capabilities.title}
+
+
{detailCards.capabilities.items.gui}
+
{detailCards.capabilities.items.cli}
+
+ {detailCards.capabilities.items.preferredExecutionMode} +
+
+
+
切换默认执行模式
+
+ {(["gui", "cli"] as const).map((mode) => ( + + ))} +
+
+
+
+
+
{detailCards.conflicts.title}
+
动作后续接入
+
+
+
{detailCards.conflicts.items.device}
+
{detailCards.conflicts.items.folderKey}
+
{detailCards.conflicts.items.projectId}
+
{detailCards.conflicts.items.allowPolicy}
+
{detailCards.conflicts.items.conflictState}
+
+
{detailCards.conflicts.scopeLabel}
+ {primaryPolicy ? ( +
+ + + +
+ ) : null} +
当前状态
diff --git a/src/lib/boss-projections.ts b/src/lib/boss-projections.ts index bec59a5..367a883 100644 --- a/src/lib/boss-projections.ts +++ b/src/lib/boss-projections.ts @@ -12,6 +12,7 @@ import type { DeviceEnrollment, DeviceImportDraft, DeviceImportResolution, + ProjectExecutionPolicy, DeviceSkill, MasterIdentitySummary, MasterAgentMemory, @@ -107,6 +108,7 @@ export interface DeviceWorkspaceView { activeEnrollment?: DeviceEnrollment; importDraft?: DeviceImportDraft; importResolution?: DeviceImportResolution; + projectExecutionPolicies?: ProjectExecutionPolicy[]; } export interface OpsSummaryView { @@ -750,12 +752,14 @@ export function getDeviceWorkspaceView( relatedThreads: [], }; } + const selectedDevice = state.devices.find((item) => item.id === deviceId); return { - selectedDevice: state.devices.find((item) => item.id === deviceId), + selectedDevice: selectedDevice ? { ...selectedDevice } : undefined, relatedThreads: state.threadContextSnapshots.filter((item) => item.nodeId === deviceId), activeEnrollment: state.deviceEnrollments.find((item) => item.deviceId === deviceId), importDraft: state.deviceImportDrafts.find((item) => item.deviceId === deviceId), importResolution: state.deviceImportResolutions.find((item) => item.deviceId === deviceId), + projectExecutionPolicies: state.projectExecutionPolicies.filter((item) => item.deviceId === deviceId), }; } diff --git a/tests/device-detail-capabilities-route.test.ts b/tests/device-detail-capabilities-route.test.ts new file mode 100644 index 0000000..7590ba9 --- /dev/null +++ b/tests/device-detail-capabilities-route.test.ts @@ -0,0 +1,97 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; + +let runtimeRoot = ""; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let writeState: (typeof import("../src/lib/boss-data"))["writeState"]; +let applyProjectConflictDecision: (typeof import("../src/lib/boss-data"))["applyProjectConflictDecision"]; +let getDeviceWorkspaceView: (typeof import("../src/lib/boss-projections"))["getDeviceWorkspaceView"]; +let buildDeviceWorkspaceDetailCards: (typeof import("../src/components/app-ui"))["buildDeviceWorkspaceDetailCards"]; + +async function setup() { + if (runtimeRoot) return; + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-detail-route-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [data, projections, ui] = await Promise.all([ + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-projections.ts"), + import("../src/components/app-ui.tsx"), + ]); + + readState = data.readState; + writeState = data.writeState; + applyProjectConflictDecision = data.applyProjectConflictDecision; + getDeviceWorkspaceView = projections.getDeviceWorkspaceView; + buildDeviceWorkspaceDetailCards = ui.buildDeviceWorkspaceDetailCards; +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +test("device detail exposes gui cli capability state and preferred execution mode", async () => { + await setup(); + + const state = await readState(); + const workspace = getDeviceWorkspaceView(state, "mac-studio"); + const cards = buildDeviceWorkspaceDetailCards(workspace); + + assert.equal(cards.capabilities.title, "执行能力"); + assert.equal(cards.capabilities.items.gui, "GUI:已连接"); + assert.equal(cards.capabilities.items.cli, "CLI:已连接"); + assert.equal(cards.capabilities.items.preferredExecutionMode, "默认执行模式:CLI"); +}); + +test("device detail exposes folder and project conflict skeleton from workspace policy", async () => { + await setup(); + + const state = await readState(); + state.projectExecutionPolicies = [ + { + deviceId: "mac-studio", + folderKey: "mac-studio:boss", + projectId: "thread-ui", + allowPolicy: "allow_always", + conflictState: "warning", + updatedAt: "2026-04-06T12:00:00.000Z", + }, + ]; + await writeState(state); + + const workspace = getDeviceWorkspaceView(await readState(), "mac-studio"); + const cards = buildDeviceWorkspaceDetailCards(workspace); + + assert.equal(cards.conflicts.title, "异常项目 / 文件夹冲突"); + assert.equal(cards.conflicts.items.device, "设备:Mac Studio"); + assert.equal(cards.conflicts.items.folderKey, "文件夹:mac-studio:boss"); + assert.equal(cards.conflicts.items.projectId, "项目:thread-ui"); + assert.equal(cards.conflicts.items.allowPolicy, "当前策略:allow_always"); + assert.equal(cards.conflicts.items.conflictState, "冲突态:warning"); +}); + +test("device detail conflict card keeps project-scoped actions on the active folder only", async () => { + await setup(); + + await applyProjectConflictDecision({ + deviceId: "mac-studio", + folderKey: "mac-studio:boss", + projectId: "thread-ui", + decision: "allow_once", + }); + + const workspace = getDeviceWorkspaceView(await readState(), "mac-studio"); + const cards = buildDeviceWorkspaceDetailCards(workspace); + + assert.equal(cards.conflicts.items.allowPolicy, "当前策略:allow_once"); + assert.equal(cards.conflicts.items.conflictState, "冲突态:warning"); + assert.deepEqual(cards.conflicts.actions, ["禁止", "允许本次", "永久放行"]); + assert.equal(cards.conflicts.scopeLabel, "仅作用于当前异常项目 / 文件夹"); +});