Wire device execution mode controls into UI
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<String, String> 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<String, String> values = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public Map<String, ?> getAll() {
|
||||
return Collections.unmodifiableMap(values);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getString(String key, String defValue) {
|
||||
return values.getOrDefault(key, defValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getStringSet(String key, Set<String> 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<String> 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) {}
|
||||
}
|
||||
}
|
||||
@@ -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<Runnable> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"masterAgentWorkdir": "/Users/kris/code/boss",
|
||||
"masterAgentSandbox": "workspace-write",
|
||||
"masterAgentModel": "gpt-5.4",
|
||||
"preferredExecutionMode": "cli",
|
||||
"guiConnected": false,
|
||||
"omxEnabled": false,
|
||||
"omxCommand": "",
|
||||
"omxArgs": [],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -55,6 +55,7 @@ export default async function DevicesPage({
|
||||
device={workspace.selectedDevice}
|
||||
relatedThreads={workspace.relatedThreads}
|
||||
activeEnrollment={workspace.activeEnrollment}
|
||||
workspace={workspace}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<DeviceImportDraftManager
|
||||
|
||||
@@ -37,7 +37,7 @@ import type {
|
||||
UserProfile,
|
||||
UserSettings,
|
||||
} from "@/lib/boss-data";
|
||||
import type { ConversationItem } from "@/lib/boss-projections";
|
||||
import type { ConversationItem, DeviceWorkspaceView } from "@/lib/boss-projections";
|
||||
import { formatTimestampLabel } from "@/lib/boss-projections";
|
||||
|
||||
function formatClock(value: string) {
|
||||
@@ -55,6 +55,40 @@ function boundDeviceIdFromDom() {
|
||||
return document.body.dataset.boundDeviceId || "mac-studio";
|
||||
}
|
||||
|
||||
export function buildDeviceWorkspaceDetailCards(workspace: DeviceWorkspaceView) {
|
||||
const selectedDevice = workspace.selectedDevice;
|
||||
const primaryPolicy = workspace.projectExecutionPolicies?.[0];
|
||||
|
||||
return {
|
||||
capabilities: {
|
||||
title: "执行能力",
|
||||
items: {
|
||||
gui: `GUI:${selectedDevice?.capabilities?.gui?.connected ? "已连接" : "未连接"}`,
|
||||
cli: `CLI:${selectedDevice?.capabilities?.cli?.connected ? "已连接" : "未连接"}`,
|
||||
preferredExecutionMode: `默认执行模式:${
|
||||
selectedDevice?.preferredExecutionMode === "gui"
|
||||
? "GUI"
|
||||
: selectedDevice?.preferredExecutionMode === "cli"
|
||||
? "CLI"
|
||||
: "未知"
|
||||
}`,
|
||||
},
|
||||
},
|
||||
conflicts: {
|
||||
title: "异常项目 / 文件夹冲突",
|
||||
scopeLabel: "仅作用于当前异常项目 / 文件夹",
|
||||
actions: ["禁止", "允许本次", "永久放行"],
|
||||
items: {
|
||||
device: `设备:${selectedDevice?.name ?? selectedDevice?.id ?? "未知设备"}`,
|
||||
folderKey: `文件夹:${primaryPolicy?.folderKey ?? "暂无"}`,
|
||||
projectId: `项目:${primaryPolicy?.projectId ?? "暂无"}`,
|
||||
allowPolicy: `当前策略:${primaryPolicy?.allowPolicy ?? "暂无"}`,
|
||||
conflictState: `冲突态:${primaryPolicy?.conflictState ?? "暂无"}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForLoginSessionReady(nativeClient: boolean) {
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const response = await fetch("/api/auth/session", {
|
||||
@@ -560,16 +594,23 @@ export function DeviceEditorCard({
|
||||
device,
|
||||
relatedThreads,
|
||||
activeEnrollment,
|
||||
workspace,
|
||||
}: {
|
||||
device: Device;
|
||||
relatedThreads: ThreadContextSnapshot[];
|
||||
activeEnrollment?: DeviceEnrollment;
|
||||
workspace: DeviceWorkspaceView;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const detailCards = buildDeviceWorkspaceDetailCards(workspace);
|
||||
const primaryPolicy = workspace.projectExecutionPolicies?.[0];
|
||||
const [name, setName] = useState(device.name);
|
||||
const [avatar, setAvatar] = useState(device.avatar);
|
||||
const [account, setAccount] = useState(device.account);
|
||||
const [status, setStatus] = useState<Device["status"]>(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 (
|
||||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -607,10 +668,88 @@ export function DeviceEditorCard({
|
||||
<Field label="设备名称" value={name} onChange={setName} />
|
||||
<Field label="头像缩写" value={avatar} onChange={setAvatar} />
|
||||
</div>
|
||||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">{detailCards.capabilities.title}</div>
|
||||
<div className="grid gap-2 text-[13px] leading-6 text-[#57606A]">
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.capabilities.items.gui}</div>
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.capabilities.items.cli}</div>
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||||
{detailCards.capabilities.items.preferredExecutionMode}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">切换默认执行模式</div>
|
||||
<div className="flex gap-2">
|
||||
{(["gui", "cli"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setPreferredExecutionMode(mode)}
|
||||
className={clsx(
|
||||
"rounded-full px-3 py-2 text-[12px] font-semibold",
|
||||
preferredExecutionMode === mode
|
||||
? "bg-[#07C160] text-white"
|
||||
: "bg-[#F5F5F7] text-[#57606A]",
|
||||
)}
|
||||
>
|
||||
{mode === "gui" ? "GUI" : "CLI"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Field label="账号" value={account} onChange={setAccount} />
|
||||
<Field label="Endpoint" value={endpoint} onChange={setEndpoint} />
|
||||
<Field label="备注" value={note} onChange={setNote} />
|
||||
<Field label="项目列表(逗号分隔)" value={projects} onChange={setProjects} />
|
||||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">{detailCards.conflicts.title}</div>
|
||||
<div className="text-[12px] text-[#8C8C8C]">动作后续接入</div>
|
||||
</div>
|
||||
<div className="grid gap-2 text-[13px] leading-6 text-[#57606A]">
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.device}</div>
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.folderKey}</div>
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.projectId}</div>
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.allowPolicy}</div>
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.conflictState}</div>
|
||||
</div>
|
||||
<div className="text-[12px] text-[#8C8C8C]">{detailCards.conflicts.scopeLabel}</div>
|
||||
{primaryPolicy ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveConflictDecision("forbid")}
|
||||
className={clsx(
|
||||
"rounded-full px-3 py-2 text-[12px] font-semibold",
|
||||
primaryPolicy.allowPolicy === "forbid" ? "bg-[#111111] text-white" : "bg-[#F5F5F7] text-[#57606A]",
|
||||
)}
|
||||
>
|
||||
{detailCards.conflicts.actions[0]}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveConflictDecision("allow_once")}
|
||||
className={clsx(
|
||||
"rounded-full px-3 py-2 text-[12px] font-semibold",
|
||||
primaryPolicy.allowPolicy === "allow_once" ? "bg-[#07C160] text-white" : "bg-[#F5F5F7] text-[#57606A]",
|
||||
)}
|
||||
>
|
||||
{detailCards.conflicts.actions[1]}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveConflictDecision("allow_always")}
|
||||
className={clsx(
|
||||
"rounded-full px-3 py-2 text-[12px] font-semibold",
|
||||
primaryPolicy.allowPolicy === "allow_always" ? "bg-[#2563EB] text-white" : "bg-[#F5F5F7] text-[#57606A]",
|
||||
)}
|
||||
>
|
||||
{detailCards.conflicts.actions[2]}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">当前状态</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
97
tests/device-detail-capabilities-route.test.ts
Normal file
97
tests/device-detail-capabilities-route.test.ts
Normal file
@@ -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, "仅作用于当前异常项目 / 文件夹");
|
||||
});
|
||||
Reference in New Issue
Block a user