diff --git a/README.md b/README.md index ad44b22..a9d8fb7 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ - `src/boss_control`:空占位目录,不参与当前运行 - `src/boss_device_agent`:空占位目录,不参与当前运行 -## 当前运行状态(2026-03-31) +## 当前运行状态(2026-04-03) 本地: @@ -59,8 +59,8 @@ - 当前 Boss 已新增 `src/lib/execution/` 执行底座抽象层;当前生产主链仍然沿用 `local-agent -> codex exec resume`,只是执行责任已开始通过 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现收束 - 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有在显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话里才会出现并允许选择 `claw-runtime` - 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在前台显示明确原因 -- 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;目前只完成编排后端骨架、selector 与 smoke runtime,还没有接入生产群聊/审批主链 -- 当前仓库已自带一个本地 OMX smoke runtime:`scripts/omx-team-smoke.mjs`。在还没有真实 `oh-my-codex` 可执行文件时,可以先用它验证 `OmxTeamBackendAdapter -> selector -> fallback` 这条骨架链 +- 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native` 与 `OMX Team` 间切换,OMX 不可用时会自动回退到默认后端并明确提示原因 +- 当前仓库已自带一个本地 OMX smoke runtime:`scripts/omx-team-smoke.mjs`。在还没有真实 `oh-my-codex` 可执行文件时,可以先用它验证 `OmxTeamBackendAdapter -> selector -> fallback` 这条链 - 当前仓库已自带一个本地 smoke runtime:`scripts/claw-runtime-smoke.mjs`。在还没有真实 `claw-code` 可执行文件时,可以先用它验证 `ClawBackendAdapter -> backendOverride -> 异步回流` 整条链 - `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill - `POST http://127.0.0.1:4317/api/v1/heartbeat` 正常,且会顺带触发 `thread-context` 上报 @@ -101,7 +101,7 @@ Android APK: - 已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk` - 已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk` - `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk` -- 当前最新 release 构建版本:`2.5.8`(`versionCode=21`) +- 当前最新 release 构建版本:`2.5.9`(`versionCode=22`) - 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局` - 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、主 Agent 提示词 / 记忆、技能、运维中心、关于 - 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口 diff --git a/android/app/build.gradle b/android/app/build.gradle index e19ca56..427a125 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -36,8 +36,8 @@ android { applicationId "com.hyzq.boss" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 21 - versionName "2.5.8" + versionCode 22 + versionName "2.5.9" buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } 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 4a9d3df..4a26633 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -126,6 +126,16 @@ public class BossApiClient { return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload); } + public ApiResponse getProjectOrchestrationBackend(String projectId) throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/orchestration-backend", null); + } + + public ApiResponse updateProjectOrchestrationBackend(String projectId, @Nullable String requestedBackendId) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("requestedBackendId", requestedBackendId == null ? JSONObject.NULL : requestedBackendId); + return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/orchestration-backend", payload); + } + public ApiResponse getMasterAgentPromptProfile(String projectId) throws IOException, JSONException { return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/prompt-profile", null); } diff --git a/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java index 7d53fc2..918f9ee 100644 --- a/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java @@ -48,7 +48,11 @@ public class GroupInfoActivity extends BossScreenActivity { if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message()); BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId); if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message()); - runOnUiThread(() -> renderGroup(detailResponse.json, participantsResponse.json)); + BossApiClient.ApiResponse orchestrationResponse = apiClient.getProjectOrchestrationBackend(projectId); + JSONObject orchestrationBackend = orchestrationResponse.ok() + ? orchestrationResponse.json + : buildFallbackOrchestrationBackendPayload(orchestrationResponse.message()); + runOnUiThread(() -> renderGroup(detailResponse.json, participantsResponse.json, orchestrationBackend)); } catch (Exception error) { runOnUiThread(() -> { setRefreshing(false); @@ -59,6 +63,10 @@ public class GroupInfoActivity extends BossScreenActivity { } private void renderGroup(JSONObject detail, JSONObject participantsPayload) { + renderGroup(detail, participantsPayload, null); + } + + private void renderGroup(JSONObject detail, JSONObject participantsPayload, @Nullable JSONObject orchestrationBackendPayload) { replaceContent(); JSONObject project = detail.optJSONObject("project"); JSONArray participants = participantsPayload.optJSONArray("participants"); @@ -94,6 +102,9 @@ public class GroupInfoActivity extends BossScreenActivity { null, v -> openProject(projectId, projectName) )); + if (orchestrationBackendPayload != null) { + appendContent(buildOrchestrationBackendRow(orchestrationBackendPayload)); + } if (repairRequired) { String meta = invalidParticipantCount > 0 @@ -316,6 +327,171 @@ public class GroupInfoActivity extends BossScreenActivity { }); } + private LinearLayout buildOrchestrationBackendRow(JSONObject backendPayload) { + String requestedBackendId = backendPayload.optString("requestedBackendId", "boss-native-orchestrator"); + String currentBackendId = backendPayload.optString("currentBackendId", requestedBackendId); + JSONObject omxAvailability = backendPayload.optJSONObject("omxAvailability"); + String currentLabel = resolveBackendLabel(backendPayload, currentBackendId); + String requestedLabel = resolveBackendLabel(backendPayload, requestedBackendId); + String subtitle = "当前:" + currentLabel; + if (!TextUtils.equals(currentBackendId, requestedBackendId)) { + subtitle += " · 请求:" + requestedLabel; + } + boolean omxSelectable = omxAvailability != null && omxAvailability.optBoolean("selectable", false); + boolean fallbackActive = !TextUtils.equals(currentBackendId, requestedBackendId); + if (omxAvailability != null) { + subtitle += omxSelectable ? " · OMX 可用" : " · OMX 受限"; + } + String meta = omxAvailability == null + ? "等待后端状态" + : buildOrchestrationBackendAvailabilitySummary(omxAvailability, fallbackActive); + String badge = fallbackActive ? "回退" : (omxSelectable ? "当前" : "受限"); + return BossUi.buildWechatMenuRow( + this, + "编排后端", + subtitle, + meta, + badge, + v -> openOrchestrationBackendDialog(backendPayload) + ); + } + + private void openOrchestrationBackendDialog(JSONObject backendPayload) { + JSONArray availableChoices = backendPayload.optJSONArray("availableChoices"); + if (availableChoices == null || availableChoices.length() == 0) { + showMessage("编排后端状态暂不可用"); + return; + } + + CharSequence[] items = new CharSequence[availableChoices.length()]; + final String[] backendIds = new String[availableChoices.length()]; + final boolean[] selectable = new boolean[availableChoices.length()]; + final String omxReason = backendPayload.optJSONObject("omxAvailability") == null + ? "OMX Team Runtime 当前不可用。" + : backendPayload.optJSONObject("omxAvailability").optString("reasonLabel", "OMX Team Runtime 当前不可用。"); + final boolean omxSelectable = backendPayload.optJSONObject("omxAvailability") != null + && backendPayload.optJSONObject("omxAvailability").optBoolean("selectable", false); + for (int i = 0; i < availableChoices.length(); i++) { + JSONObject choice = availableChoices.optJSONObject(i); + if (choice == null) { + items[i] = "未命名后端"; + backendIds[i] = ""; + selectable[i] = false; + continue; + } + backendIds[i] = choice.optString("backendId", ""); + selectable[i] = choice.optBoolean("selectable", false); + String label = resolveBackendLabel(backendPayload, backendIds[i]); + items[i] = label + (selectable[i] ? "" : "(不可用)"); + } + + new AlertDialog.Builder(this) + .setTitle("选择编排后端") + .setMessage(omxSelectable + ? "Boss Native Orchestrator 永远可用;OMX Team Runtime 当前可直接切换。" + : "Boss Native Orchestrator 永远可用;OMX Team Runtime 当前不可用,切换时会自动回退到 Boss Native Orchestrator。") + .setItems(items, (dialog, which) -> { + String selectedBackendId = backendIds[which]; + if (TextUtils.isEmpty(selectedBackendId)) { + showMessage("编排后端选择无效"); + return; + } + if (!selectable[which] && TextUtils.equals(selectedBackendId, "omx-team")) { + showMessage(omxReason); + return; + } + saveOrchestrationBackend(selectedBackendId); + }) + .setNegativeButton("取消", null) + .show(); + } + + private void saveOrchestrationBackend(String requestedBackendId) { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.updateProjectOrchestrationBackend(projectId, requestedBackendId); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage("编排后端已更新为 " + resolveBackendLabelForId(requestedBackendId)); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("保存失败:" + error.getMessage()); + }); + } + }); + } + + private String resolveBackendLabel(JSONObject backendPayload, String backendId) { + JSONArray availableChoices = backendPayload.optJSONArray("availableChoices"); + if (availableChoices != null) { + for (int i = 0; i < availableChoices.length(); i++) { + JSONObject choice = availableChoices.optJSONObject(i); + if (choice == null) continue; + if (TextUtils.equals(choice.optString("backendId", ""), backendId)) { + return choice.optString("label", resolveBackendLabelForId(backendId)); + } + } + } + return resolveBackendLabelForId(backendId); + } + + private String resolveBackendLabelForId(String backendId) { + if (TextUtils.equals(backendId, "omx-team")) { + return "OMX Team Runtime"; + } + return "Boss Native Orchestrator"; + } + + private String normalizeOrchestrationReasonLabel(String value) { + String trimmed = value == null ? "" : value.trim(); + if (trimmed.endsWith("。") || trimmed.endsWith(".")) { + return trimmed.substring(0, trimmed.length() - 1); + } + return trimmed; + } + + private String buildOrchestrationBackendAvailabilitySummary(JSONObject omxAvailability, boolean fallbackActive) { + if (omxAvailability.optBoolean("selectable", false)) { + return "OMX Team Runtime 当前可用,当前可切换到该后端。"; + } + String reasonLabel = normalizeOrchestrationReasonLabel( + omxAvailability.optString("reasonLabel", "OMX Team Runtime 当前不可用。") + ); + return fallbackActive + ? reasonLabel + ",当前已自动回退到 Boss Native Orchestrator。" + : reasonLabel + ",切换后会自动回退到 Boss Native Orchestrator。"; + } + + private JSONObject buildFallbackOrchestrationBackendPayload(String reason) { + try { + JSONArray availableChoices = new JSONArray() + .put(new JSONObject() + .put("backendId", "boss-native-orchestrator") + .put("label", "Boss Native Orchestrator") + .put("selectable", true) + .put("current", true)) + .put(new JSONObject() + .put("backendId", "omx-team") + .put("label", "OMX Team Runtime") + .put("selectable", false) + .put("current", false)); + return new JSONObject() + .put("currentBackendId", "boss-native-orchestrator") + .put("requestedBackendId", "boss-native-orchestrator") + .put("availableChoices", availableChoices) + .put("omxAvailability", new JSONObject() + .put("selectable", false) + .put("reason", "disabled") + .put("reasonLabel", TextUtils.isEmpty(reason) ? "OMX Team Runtime 当前不可用。" : reason)); + } catch (Exception error) { + return new JSONObject(); + } + } + private String buildSubtitle(String folderName, int count) { String memberLabel = count <= 0 ? "暂无成员" : count + " 个成员"; if (folderName.isEmpty()) { diff --git a/android/app/src/test/java/com/hyzq/boss/BossApiClientOrchestrationBackendTest.java b/android/app/src/test/java/com/hyzq/boss/BossApiClientOrchestrationBackendTest.java new file mode 100644 index 0000000..f07a58a --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/BossApiClientOrchestrationBackendTest.java @@ -0,0 +1,244 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; + +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 BossApiClientOrchestrationBackendTest { + @Test + public void getProjectOrchestrationBackendUsesScopedEndpoint() throws Exception { + RecordingConnection connection = new RecordingConnection( + new URL("https://boss.hyzq.net/api/v1/projects/audit-collab/orchestration-backend") + ); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + BossApiClient.ApiResponse response = apiClient.getProjectOrchestrationBackend("audit-collab"); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/projects/audit-collab/orchestration-backend", apiClient.lastPath); + assertEquals("GET", connection.requestMethodValue); + } + + @Test + public void updateProjectOrchestrationBackendWritesRequestedBackendId() throws Exception { + RecordingConnection connection = new RecordingConnection( + new URL("https://boss.hyzq.net/api/v1/projects/audit-collab/orchestration-backend") + ); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + BossApiClient.ApiResponse response = apiClient.updateProjectOrchestrationBackend("audit-collab", "omx-team"); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/projects/audit-collab/orchestration-backend", apiClient.lastPath); + assertEquals("PATCH", connection.requestMethodValue); + assertEquals("{\"requestedBackendId\":\"omx-team\"}", 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) { + // No-op for JVM tests. + } + } + + 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/GroupInfoActivityTest.java b/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java index 8264947..665fecf 100644 --- a/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java @@ -6,6 +6,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import android.content.Intent; +import android.os.Looper; import android.view.View; import android.view.ViewGroup; import android.widget.LinearLayout; @@ -21,6 +22,22 @@ import org.robolectric.Shadows; import org.robolectric.annotation.Config; import org.robolectric.util.ReflectionHelpers; +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; +import java.util.List; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.TimeUnit; + @RunWith(RobolectricTestRunner.class) @Config(sdk = 34) public class GroupInfoActivityTest { @@ -115,6 +132,80 @@ public class GroupInfoActivityTest { assertTrue(viewTreeContainsText(content, "失效")); } + @Test + public void renderGroupShowsOrchestrationBackendStateAndFallbackReason() throws Exception { + Intent intent = new Intent() + .putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1") + .putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群"); + TestGroupInfoActivity activity = Robolectric + .buildActivity(TestGroupInfoActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderGroup", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()), + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()), + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildOrchestrationBackendPayload()) + ); + + LinearLayout content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "编排后端")); + assertTrue(viewTreeContainsText(content, "当前:Boss Native Orchestrator · OMX 可用")); + assertTrue(viewTreeContainsText(content, "OMX Team Runtime 当前可用,当前可切换到该后端。")); + } + + @Test + public void renderGroupShowsOmxFallbackHintWhenOmxRuntimeIsUnavailable() throws Exception { + Intent intent = new Intent() + .putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1") + .putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群"); + TestGroupInfoActivity activity = Robolectric + .buildActivity(TestGroupInfoActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderGroup", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()), + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()), + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildUnavailableOrchestrationBackendPayload()) + ); + + LinearLayout content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "当前:Boss Native Orchestrator · 请求:OMX Team Runtime · OMX 受限")); + assertTrue(viewTreeContainsText(content, "OMX Team Runtime 当前不可用,当前已自动回退到 Boss Native Orchestrator。")); + } + + @Test + public void saveOrchestrationBackendUsesScopedEndpoint() throws Exception { + Intent intent = new Intent() + .putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1") + .putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群"); + TestGroupInfoActivity activity = Robolectric + .buildActivity(TestGroupInfoActivity.class, intent) + .setup() + .get(); + RecordingConnection connection = new RecordingConnection( + new URL("https://boss.hyzq.net/api/v1/projects/group-1/orchestration-backend") + ); + ReflectionHelpers.setField(activity, "apiClient", new RecordingBossApiClient(connection)); + ReflectionHelpers.setField(activity, "executor", new DirectExecutorService()); + + ReflectionHelpers.callInstanceMethod( + activity, + "saveOrchestrationBackend", + ReflectionHelpers.ClassParameter.from(String.class, "omx-team") + ); + Shadows.shadowOf(Looper.getMainLooper()).idle(); + + assertEquals("/api/v1/projects/group-1/orchestration-backend", connection.lastPath); + assertEquals("PATCH", connection.requestMethodValue); + assertEquals("{\"requestedBackendId\":\"omx-team\"}", connection.requestBody()); + } + private static JSONObject buildDetailPayload() throws Exception { JSONObject threadMeta = new JSONObject() .put("threadId", "group-thread-3") @@ -165,6 +256,50 @@ public class GroupInfoActivityTest { .put("invalidParticipantCount", 1); } + private static JSONObject buildOrchestrationBackendPayload() throws Exception { + JSONArray availableChoices = new JSONArray() + .put(new JSONObject() + .put("backendId", "boss-native-orchestrator") + .put("label", "Boss Native Orchestrator") + .put("selectable", true) + .put("current", true)) + .put(new JSONObject() + .put("backendId", "omx-team") + .put("label", "OMX Team Runtime") + .put("selectable", true) + .put("current", false)); + return new JSONObject() + .put("currentBackendId", "boss-native-orchestrator") + .put("requestedBackendId", "boss-native-orchestrator") + .put("availableChoices", availableChoices) + .put("omxAvailability", new JSONObject() + .put("selectable", true) + .put("reason", "ready") + .put("reasonLabel", "OMX Team Runtime 可用。")); + } + + private static JSONObject buildUnavailableOrchestrationBackendPayload() throws Exception { + JSONArray availableChoices = new JSONArray() + .put(new JSONObject() + .put("backendId", "boss-native-orchestrator") + .put("label", "Boss Native Orchestrator") + .put("selectable", true) + .put("current", true)) + .put(new JSONObject() + .put("backendId", "omx-team") + .put("label", "OMX Team Runtime") + .put("selectable", false) + .put("current", false)); + return new JSONObject() + .put("currentBackendId", "boss-native-orchestrator") + .put("requestedBackendId", "omx-team") + .put("availableChoices", availableChoices) + .put("omxAvailability", new JSONObject() + .put("selectable", false) + .put("reason", "script_not_found") + .put("reasonLabel", "OMX Team Runtime 当前不可用。")); + } + private static boolean viewTreeContainsText(View root, String expectedText) { if (root instanceof TextView) { CharSequence text = ((TextView) root).getText(); @@ -210,4 +345,221 @@ public class GroupInfoActivityTest { // Tests render the lightweight info state directly. } } + + private static final class RecordingBossApiClient extends BossApiClient { + private final RecordingConnection connection; + + RecordingBossApiClient(RecordingConnection connection) { + super(new InMemorySharedPreferences(), "https://boss.hyzq.net"); + this.connection = connection; + } + + @Override + HttpURLConnection openConnection(String path) { + connection.lastPath = path; + return connection; + } + + @Override + String encode(String value) { + return value; + } + } + + private static final class RecordingConnection extends HttpURLConnection { + private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream(); + private final Map requestHeaders = new HashMap<>(); + private String requestMethodValue = "GET"; + private String lastPath = ""; + + 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 android.content.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) {} + } + + private static final class DirectExecutorService extends AbstractExecutorService { + private boolean shutdown; + + @Override + public void shutdown() { + shutdown = true; + } + + @Override + public List shutdownNow() { + shutdown = true; + return java.util.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/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index 20472ce..23ff0da 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -180,7 +180,7 @@ - 当前已最小接入 `ClawBackendAdapter`,但默认关闭,仅在显式配置且可用性探测通过时才参与执行 - 如果历史 `backendOverride=claw-runtime` 当前不可用,运行时会自动回退到默认后端,并把原因回给前台 - 当前仓库自带 `scripts/claw-runtime-smoke.mjs` 作为兼容 JSON 协议的 smoke runtime,可用于本地和服务器验证 `ClawBackendAdapter` - - 当前已最小接入 `OmxTeamBackendAdapter`,但默认关闭,仅提供编排后端骨架、selector 与 smoke runtime + - 当前已最小接入 `OmxTeamBackendAdapter`,但默认关闭;Web 群聊详情页和原生群资料页已经可以在 `Boss Native` 与 `OMX Team` 间切换编排后端,OMX 不可用时会自动回退到默认后端并返回明确原因 - 当前仓库自带 `scripts/omx-team-smoke.mjs`,可用于本地和服务器验证 `OmxTeamBackendAdapter` ### 3.2 认证相关 @@ -392,6 +392,32 @@ - 只有在 `Claw Runtime` 可用性探测通过时才允许保存 `claw-runtime` - 显式传 `null` 或空字符串表示清空覆盖;省略字段表示保留原值 +#### `GET /api/v1/projects/[projectId]/orchestration-backend` + +- 用途:读取群聊当前的编排后端状态 +- 返回: + - `currentBackendId` + - `currentBackendLabel` + - `requestedBackendId` + - `requestedBackendLabel` + - `availableChoices[]` + - `omxAvailability` +- 当前行为: + - 当没有显式覆盖时,API 会把 `requestedBackendId` 视为 `null` + - 当前实际生效的默认后端仍是 `boss-native-orchestrator` + - `Boss Native` / `OMX Team` 选择会同时暴露给 Web 群聊页和原生群资料页 + +#### `PATCH /api/v1/projects/[projectId]/orchestration-backend` + +- 用途:更新群聊的编排后端偏好 +- 输入: + - `requestedBackendId` +- 当前行为: + - `requestedBackendId=omx-team` 时会尝试保存 `OMX Team` + - `requestedBackendId=boss-native-orchestrator` 时会回到默认编排后端 + - 如果 `OMX Team` 不可用,保存时会返回明确的回退原因 + - 该接口与 Web 群聊页、原生群资料页上的编排后端选择卡保持一致 + #### `GET /api/v1/projects/[projectId]/participants` - 用途:读取单线程会话的线程归属信息,或群聊会话的成员线程列表 diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index 42ca3e2..ff88387 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -1,6 +1,6 @@ # Boss 当前运行与部署状态 -更新时间:`2026-03-31` +更新时间:`2026-04-03` ## 1. 本地状态 @@ -31,7 +31,7 @@ - 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话中才会出现并允许选择 `claw-runtime` - 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在 Web/Android 前台给出明确原因 - 当前仓库已自带 `scripts/claw-runtime-smoke.mjs` 作为本地 smoke runtime;在没有真实 `claw-code` 可执行文件时,可先用 `BOSS_CLAW_COMMAND=node` 与 `BOSS_CLAW_ARGS=scripts/claw-runtime-smoke.mjs` 验证整条链 -- 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前只完成编排后端骨架、selector 与 smoke runtime,还没有接入生产群聊/审批主链 +- 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native` 与 `OMX Team` 间切换,OMX 不可用时会自动回退到默认后端并明确提示原因 - 当前仓库已自带 `scripts/omx-team-smoke.mjs` 作为本地 OMX smoke runtime;在没有真实 `oh-my-codex` 可执行文件时,可先用 `BOSS_OMX_COMMAND=node` 与 `BOSS_OMX_ARGS=scripts/omx-team-smoke.mjs` 验证编排后端骨架 本地已知运行方式: @@ -165,7 +165,7 @@ cd /Users/kris/code/boss - 当前已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk` - 当前已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk` - 当前 release 构建还会额外生成带版本号的 APK:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk` -- 当前最新 release 构建版本:`2.5.8`(`versionCode=21`) +- 当前最新 release 构建版本:`2.5.9`(`versionCode=22`) - 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties` - `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退 - `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity` @@ -185,6 +185,7 @@ cd /Users/kris/code/boss - `2.5.4` 已把 `设置 / 账号与安全 / AI 账号 / 技能 / 运维与修复` 的顶部说明从绿色 `soft panel` 降成轻量列表说明,和会话/设备页统一成同一套微信式产品语言 - `2.5.5` 已补上群资料页“修复群成员”主链:历史脏群现在会明确提示失效成员,并允许重新选择真实线程成员写回群资料 - `2.5.5` 已给 `approval_required` 群聊补齐“确认 / 拒绝”两条审批动作;拒绝后会把群审批状态写成 `rejected`,并追加系统提示,不再继续下发到线程 +- `2.5.9` 对应这一轮的执行底座收口:`ClawBackendAdapter` 仍默认关闭,但可显式选择并在不可用时自动回退;`OmxTeamBackendAdapter` 已接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native` 与 `OMX Team` 间切换 - 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net` - `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理 - 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列 diff --git a/src/app/api/v1/projects/[projectId]/orchestration-backend/route.ts b/src/app/api/v1/projects/[projectId]/orchestration-backend/route.ts new file mode 100644 index 0000000..cf61a97 --- /dev/null +++ b/src/app/api/v1/projects/[projectId]/orchestration-backend/route.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { + getProject, + getProjectOrchestrationBackendState, + updateProjectOrchestrationBackend, +} from "@/lib/boss-data"; + +function normalizeRequestedBackendId(value: unknown) { + return value === "omx-team" ? "omx-team" : "boss-native-orchestrator"; +} + +async function readGroupProjectOrNotFound(projectId: string) { + const project = await getProject(projectId); + if (!project) { + return { ok: false as const, response: NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }) }; + } + if (!project.isGroup) { + return { + ok: false as const, + response: NextResponse.json({ ok: false, message: "PROJECT_NOT_GROUP_CHAT" }, { status: 400 }), + }; + } + return { ok: true as const, project }; +} + +export async function GET( + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId } = await context.params; + const projectCheck = await readGroupProjectOrNotFound(projectId); + if (!projectCheck.ok) { + return projectCheck.response; + } + + const state = await getProjectOrchestrationBackendState(projectId); + if (!state) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + + return NextResponse.json({ + ok: true, + ...state, + requestedBackendId: projectCheck.project.orchestrationBackendOverride ?? null, + requestedBackendLabel: projectCheck.project.orchestrationBackendOverride + ? state.requestedBackendLabel + : null, + }); +} + +export async function PATCH( + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId } = await context.params; + const projectCheck = await readGroupProjectOrNotFound(projectId); + if (!projectCheck.ok) { + return projectCheck.response; + } + + const rawBody = await request.text().catch(() => ""); + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + return NextResponse.json({ ok: false, message: "INVALID_JSON_PAYLOAD" }, { status: 400 }); + } + + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ ok: false, message: "INVALID_ORCHESTRATION_BACKEND_PAYLOAD" }, { status: 400 }); + } + + const payload = body as { + orchestrationBackendOverride?: unknown; + backendId?: unknown; + requestedBackendId?: unknown; + }; + const hasOrchestrationBackendOverride = Object.prototype.hasOwnProperty.call( + payload, + "orchestrationBackendOverride", + ); + const hasBackendId = Object.prototype.hasOwnProperty.call(payload, "backendId"); + const hasRequestedBackendId = Object.prototype.hasOwnProperty.call(payload, "requestedBackendId"); + if (!hasOrchestrationBackendOverride && !hasBackendId && !hasRequestedBackendId) { + return NextResponse.json({ ok: false, message: "INVALID_ORCHESTRATION_BACKEND_PAYLOAD" }, { status: 400 }); + } + + const requestedBackendId = normalizeRequestedBackendId( + hasOrchestrationBackendOverride + ? payload.orchestrationBackendOverride + : hasRequestedBackendId + ? payload.requestedBackendId + : payload.backendId, + ); + + try { + await updateProjectOrchestrationBackend(projectId, requestedBackendId); + const state = await getProjectOrchestrationBackendState(projectId); + if (!state) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + + return NextResponse.json({ ok: true, ...state }); + } catch (error) { + return NextResponse.json( + { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, + { status: error instanceof Error && error.message === "PROJECT_NOT_FOUND" ? 404 : 400 }, + ); + } +} diff --git a/src/app/conversations/[projectId]/page.tsx b/src/app/conversations/[projectId]/page.tsx index 2befbcd..ef9759b 100644 --- a/src/app/conversations/[projectId]/page.tsx +++ b/src/app/conversations/[projectId]/page.tsx @@ -9,10 +9,15 @@ import { MasterIdentityPill, PageNav, ProjectHeaderActions, + ProjectOrchestrationBackendCard, StatusBar, } from "@/components/app-ui"; import { requirePageSession } from "@/lib/boss-auth"; -import { listDispatchPlansByProject, readState } from "@/lib/boss-data"; +import { + getProjectOrchestrationBackendState, + listDispatchPlansByProject, + readState, +} from "@/lib/boss-data"; import { resolveDispatchPlanComposerState } from "@/lib/dispatch-plan-ui"; import { formatTimestampLabel, getProjectDetailView } from "@/lib/boss-projections"; @@ -30,6 +35,9 @@ export default async function ProjectChatPage({ const dispatchPlanState = detail?.project.isGroup ? resolveDispatchPlanComposerState(await listDispatchPlansByProject(projectId)) : resolveDispatchPlanComposerState([]); + const orchestrationBackendState = detail?.project.isGroup + ? await getProjectOrchestrationBackendState(projectId) + : null; if (!detail) notFound(); @@ -76,6 +84,14 @@ export default async function ProjectChatPage({
+ {detail.project.isGroup && orchestrationBackendState ? ( +
+ +
+ ) : null}
主 Agent 调度结论
diff --git a/src/components/app-ui.tsx b/src/components/app-ui.tsx index 1a11456..603685b 100644 --- a/src/components/app-ui.tsx +++ b/src/components/app-ui.tsx @@ -29,6 +29,8 @@ import type { OtaUpdateLog, OpsRepairTicket, OpsRepairVerification, + ProjectOrchestrationBackendState, + OrchestrationBackendId, ThreadContextSnapshot, UserProfile, UserSettings, @@ -781,6 +783,172 @@ export function ProjectHeaderActions({ projectId }: { projectId: string }) { ); } +function orchestrationBackendChoiceLabel(choice: ProjectOrchestrationBackendState["availableChoices"][number]) { + return choice.backendId === "boss-native-orchestrator" + ? "Boss Native Orchestrator" + : "OMX Team Runtime"; +} + +function normalizeOrchestrationReasonLabel(value: string) { + const trimmed = value.trim(); + if (trimmed.endsWith("。") || trimmed.endsWith(".")) { + return trimmed.slice(0, -1); + } + return trimmed; +} + +function orchestrationBackendAvailabilityCopy( + state: ProjectOrchestrationBackendState, + fallbackActive: boolean, +) { + if (state.omxAvailability.selectable) { + return { + badge: "正常", + summary: "OMX Team Runtime 当前可用,当前可切换到该后端。", + }; + } + + return { + badge: fallbackActive ? "已回退" : "OMX 受限", + summary: fallbackActive + ? `${normalizeOrchestrationReasonLabel(state.omxAvailability.reasonLabel)},当前已自动回退到 Boss Native Orchestrator。` + : `${normalizeOrchestrationReasonLabel(state.omxAvailability.reasonLabel)},切换后会自动回退到 Boss Native Orchestrator。`, + }; +} + +export function ProjectOrchestrationBackendCard({ + projectId, + initialState, +}: { + projectId: string; + initialState: ProjectOrchestrationBackendState; +}) { + const router = useRouter(); + const [state, setState] = useState(initialState); + const [savingBackendId, setSavingBackendId] = useState(null); + const [message, setMessage] = useState(""); + + const fallbackActive = state.requestedBackendId !== state.currentBackendId; + const availabilityCopy = orchestrationBackendAvailabilityCopy(state, fallbackActive); + + async function saveBackend(requestedBackendId: OrchestrationBackendId) { + setSavingBackendId(requestedBackendId); + const response = await fetch(`/api/v1/projects/${projectId}/orchestration-backend`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestedBackendId }), + }); + const result = (await response.json()) as { + ok: boolean; + message?: string; + currentBackendId?: OrchestrationBackendId; + currentBackendLabel?: string; + requestedBackendId?: OrchestrationBackendId; + requestedBackendLabel?: string; + availableChoices?: ProjectOrchestrationBackendState["availableChoices"]; + omxAvailability?: ProjectOrchestrationBackendState["omxAvailability"]; + }; + setSavingBackendId(null); + if ( + !result.ok || + !result.currentBackendId || + !result.currentBackendLabel || + !result.requestedBackendId || + !result.requestedBackendLabel || + !result.availableChoices || + !result.omxAvailability + ) { + setMessage(result.message ?? "保存失败"); + return; + } + setState({ + projectId, + currentBackendId: result.currentBackendId, + currentBackendLabel: result.currentBackendLabel, + requestedBackendId: result.requestedBackendId, + requestedBackendLabel: result.requestedBackendLabel, + availableChoices: result.availableChoices, + omxAvailability: result.omxAvailability, + }); + setMessage( + requestedBackendId === "omx-team" + ? "已切换到 OMX Team Runtime。" + : "已切换回 Boss Native Orchestrator。", + ); + router.refresh(); + } + + return ( +
+
+
+
编排后端
+
+ 当前生效:{state.currentBackendLabel} +
+ 当前请求:{state.requestedBackendLabel} +
+
+
+ {availabilityCopy.badge} +
+
+
+ {state.availableChoices.map((choice) => { + const active = choice.current; + const selectable = choice.selectable && savingBackendId !== choice.backendId; + return ( + + ); + })} +
+
+ {availabilityCopy.summary} +
+ {message ? ( +
+ {message} +
+ ) : null} +
+ ); +} + function masterIdentityPillClasses(role: MasterIdentitySummary["role"]) { switch (role) { case "primary": diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 52ca91c..5f3e899 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -6,6 +6,13 @@ import { publishBossEvent } from "@/lib/boss-events"; import type { VerificationDeliveryMode } from "@/lib/boss-mail"; import { getFixedVerificationCode, getVerificationDeliveryMode } from "@/lib/boss-mail"; import { getPublishedOtaAsset } from "@/lib/boss-ota"; +import { BOSS_NATIVE_ORCHESTRATOR } from "@/lib/execution/backends/boss-native-orchestrator"; +import { + OMX_TEAM_BACKEND, + getOmxTeamBackendSelectionState, + type OmxTeamBackendSelectionState, +} from "@/lib/execution/backends/omx-team-backend"; +import { selectOrchestrationBackend } from "@/lib/execution/orchestration-backend-selector"; export type DeviceStatus = "online" | "abnormal" | "offline"; export type DeviceSource = "production" | "demo"; @@ -144,6 +151,8 @@ export type DispatchPlanStatus = | "dispatched"; export type DispatchExecutionStatus = "queued" | "running" | "completed" | "failed"; export type ReasoningEffort = "low" | "medium" | "high"; +export type OrchestrationBackendId = import("@/lib/execution/orchestration-backend").OrchestrationBackendId; +export type OrchestrationBackendOverride = "omx-team"; export interface UserSettings { liveUpdates: boolean; @@ -305,6 +314,7 @@ export interface Project { createdByAgent: boolean; collaborationMode: "development" | "approval_required"; approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected"; + orchestrationBackendOverride?: OrchestrationBackendOverride; agentControls?: ProjectAgentControls; unreadCount: number; riskLevel: RiskLevel; @@ -334,6 +344,10 @@ export interface DispatchPlan { status: DispatchPlanStatus; targets: DispatchPlanTarget[]; summary: string; + requestedOrchestrationBackendId?: OrchestrationBackendOverride; + orchestrationBackendId?: OrchestrationBackendId; + orchestrationBackendLabel?: string; + orchestrationFallbackReason?: string; createdAt: string; confirmedAt?: string; confirmedBy?: string; @@ -347,6 +361,8 @@ export interface DispatchExecution { targetProjectId: string; targetThreadId: string; deviceId: string; + orchestrationBackendId?: OrchestrationBackendId; + orchestrationBackendLabel?: string; status: DispatchExecutionStatus; createdAt: string; completedAt?: string; @@ -382,6 +398,23 @@ export interface ProjectAgentControls { updatedAt: string; } +export interface ProjectOrchestrationBackendChoice { + backendId: OrchestrationBackendId; + label: string; + selectable: boolean; + current: boolean; +} + +export interface ProjectOrchestrationBackendState { + projectId: string; + requestedBackendId: OrchestrationBackendId; + currentBackendId: OrchestrationBackendId; + currentBackendLabel: string; + requestedBackendLabel: string; + availableChoices: ProjectOrchestrationBackendChoice[]; + omxAvailability: OmxTeamBackendSelectionState["availability"]; +} + export interface UserProjectAgentControls { account: string; projectId: string; @@ -607,6 +640,8 @@ export interface MasterAgentTask { targetThreadDisplayName?: string; targetCodexThreadRef?: string; targetCodexFolderRef?: string; + orchestrationBackendId?: OrchestrationBackendId; + orchestrationBackendLabel?: string; deviceImportDraftId?: string; status: MasterAgentTaskStatus; requestedAt: string; @@ -1726,6 +1761,12 @@ function parseBackendOverride(value: unknown) { return { kind: "set" as const, value: "claw-runtime" as const }; } +function normalizeOrchestrationBackendOverride( + value: unknown, +): OrchestrationBackendOverride | undefined { + return value === "omx-team" ? "omx-team" : undefined; +} + function normalizeStringSet(values: string[]) { return dedupeStrings(values.map((value) => value.trim()).filter(Boolean)).sort((a, b) => a.localeCompare(b)); } @@ -1913,6 +1954,20 @@ function normalizeDispatchPlan(raw: Partial, fallback?: DispatchPl status: raw.status ?? fallback?.status ?? "pending_user_confirmation", targets, summary: raw.summary ?? fallback?.summary ?? "", + requestedOrchestrationBackendId: normalizeOrchestrationBackendOverride( + raw.requestedOrchestrationBackendId ?? fallback?.requestedOrchestrationBackendId, + ), + orchestrationBackendId: + raw.orchestrationBackendId === "omx-team" || raw.orchestrationBackendId === "boss-native-orchestrator" + ? raw.orchestrationBackendId + : fallback?.orchestrationBackendId ?? "boss-native-orchestrator", + orchestrationBackendLabel: + trimToDefined(raw.orchestrationBackendLabel) ?? + trimToDefined(fallback?.orchestrationBackendLabel) ?? + (raw.orchestrationBackendId === "omx-team" ? "OMX Team Runtime" : "Boss Native Orchestrator"), + orchestrationFallbackReason: + trimToDefined(raw.orchestrationFallbackReason) ?? + trimToDefined(fallback?.orchestrationFallbackReason), createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(), confirmedAt: raw.confirmedAt ?? fallback?.confirmedAt, confirmedBy: raw.confirmedBy ?? fallback?.confirmedBy, @@ -1936,6 +1991,14 @@ function normalizeDispatchExecution( targetProjectId: raw.targetProjectId ?? fallback?.targetProjectId ?? "", targetThreadId: raw.targetThreadId ?? fallback?.targetThreadId ?? "", deviceId: raw.deviceId ?? fallback?.deviceId ?? "", + orchestrationBackendId: + raw.orchestrationBackendId === "omx-team" || raw.orchestrationBackendId === "boss-native-orchestrator" + ? raw.orchestrationBackendId + : fallback?.orchestrationBackendId ?? "boss-native-orchestrator", + orchestrationBackendLabel: + trimToDefined(raw.orchestrationBackendLabel) ?? + trimToDefined(fallback?.orchestrationBackendLabel) ?? + (raw.orchestrationBackendId === "omx-team" ? "OMX Team Runtime" : "Boss Native Orchestrator"), status: raw.status ?? fallback?.status ?? "queued", createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(), completedAt: raw.completedAt ?? fallback?.completedAt, @@ -2714,6 +2777,7 @@ function normalizeProject(raw: Partial, fallback?: Project): Project { createdByAgent: raw.createdByAgent ?? false, collaborationMode: raw.collaborationMode ?? "development", approvalState: raw.approvalState ?? "not_required", + orchestrationBackendOverride: normalizeOrchestrationBackendOverride(raw.orchestrationBackendOverride), agentControls: normalizeProjectAgentControls(raw.agentControls), }; project.groupMembers = ensureArray(raw.groupMembers, []).map((member) => @@ -2846,6 +2910,11 @@ function normalizeState(raw: Partial | undefined): BossState { targetProjectId: task.targetProjectId, targetThreadId: task.targetThreadId, targetThreadDisplayName: task.targetThreadDisplayName, + orchestrationBackendId: + task.orchestrationBackendId === "omx-team" || task.orchestrationBackendId === "boss-native-orchestrator" + ? task.orchestrationBackendId + : undefined, + orchestrationBackendLabel: task.orchestrationBackendLabel, deviceImportDraftId: task.deviceImportDraftId, status: task.status ?? "queued", requestedAt: task.requestedAt ?? nowIso(), @@ -3584,6 +3653,33 @@ export async function getProject(projectId: string) { return state.projects.find((project) => project.id === projectId) ?? null; } +export async function updateProjectOrchestrationBackendOverride(input: { + projectId: string; + requestedBy: string; + orchestrationBackendOverride?: OrchestrationBackendOverride; +}) { + return mutateState((state) => { + const project = state.projects.find((item) => item.id === input.projectId); + if (!project) { + throw new Error("PROJECT_NOT_FOUND"); + } + if (!project.isGroup) { + throw new Error("PROJECT_NOT_GROUP_CHAT"); + } + requireDispatchActorSession(state, input.requestedBy); + + const nextOverride = input.orchestrationBackendOverride; + if (project.orchestrationBackendOverride === nextOverride) { + return project; + } + + project.orchestrationBackendOverride = nextOverride; + project.updatedAt = nowIso(); + project.threadMeta.updatedAt = project.updatedAt; + return project; + }); +} + export async function hasPersistedProject(projectId: string) { const rawState = await loadPersistedStateRaw(); return Array.isArray(rawState.projects) && rawState.projects.some((project) => project?.id === projectId); @@ -3731,6 +3827,87 @@ export async function updateProjectAgentControls( }); } +function projectOrchestrationRequestedBackendId(project: Project): OrchestrationBackendId { + return project.orchestrationBackendOverride ?? "boss-native-orchestrator"; +} + +async function buildProjectOrchestrationBackendState( + project: Project, +): Promise { + const requestedBackendId = projectOrchestrationRequestedBackendId(project); + const omxSelection = await getOmxTeamBackendSelectionState(); + const currentBackend = await selectOrchestrationBackend({ + requestedBackendId, + omx: omxSelection, + }); + const nativeBackend = await BOSS_NATIVE_ORCHESTRATOR.describe(); + const omxBackend = await OMX_TEAM_BACKEND.describe(); + const availableChoices: ProjectOrchestrationBackendChoice[] = [ + { + backendId: nativeBackend.backendId as OrchestrationBackendId, + label: nativeBackend.label, + selectable: true, + current: currentBackend.backendId === nativeBackend.backendId, + }, + { + backendId: omxBackend.backendId as OrchestrationBackendId, + label: omxBackend.label, + selectable: omxSelection.selectable, + current: currentBackend.backendId === omxBackend.backendId, + }, + ]; + + return { + projectId: project.id, + requestedBackendId, + currentBackendId: currentBackend.backendId as OrchestrationBackendId, + currentBackendLabel: + availableChoices.find((choice) => choice.backendId === currentBackend.backendId)?.label ?? + nativeBackend.label, + requestedBackendLabel: + availableChoices.find((choice) => choice.backendId === requestedBackendId)?.label ?? + nativeBackend.label, + availableChoices, + omxAvailability: omxSelection.availability, + }; +} + +export async function getProjectOrchestrationBackendState( + projectId: string, +): Promise { + const state = await readState(); + const project = state.projects.find((item) => item.id === projectId); + if (!project) { + return null; + } + return buildProjectOrchestrationBackendState(project); +} + +export async function updateProjectOrchestrationBackend( + projectId: string, + requestedBackendId: OrchestrationBackendId, +) { + return mutateStateIfChanged(async (state) => { + const project = state.projects.find((item) => item.id === projectId); + if (!project) { + throw new Error("PROJECT_NOT_FOUND"); + } + + const nextOverride = + requestedBackendId === "boss-native-orchestrator" ? undefined : "omx-team"; + const currentRequestedBackendId = projectOrchestrationRequestedBackendId(project); + if (currentRequestedBackendId === requestedBackendId && project.orchestrationBackendOverride === nextOverride) { + return { result: project.orchestrationBackendOverride ?? null, changed: false }; + } + + project.orchestrationBackendOverride = nextOverride; + const updatedAt = nowIso(); + project.updatedAt = updatedAt; + project.threadMeta.updatedAt = updatedAt; + return { result: project.orchestrationBackendOverride ?? null, changed: true }; + }); +} + export async function getDevice(deviceId: string) { const state = await readState(); return state.devices.find((device) => device.id === deviceId) ?? null; @@ -4914,6 +5091,8 @@ export async function queueMasterAgentTask(payload: { targetThreadDisplayName?: string; targetCodexThreadRef?: string; targetCodexFolderRef?: string; + orchestrationBackendId?: OrchestrationBackendId; + orchestrationBackendLabel?: string; }) { const task = await mutateState((state) => { const task: MasterAgentTask = { @@ -4941,6 +5120,8 @@ export async function queueMasterAgentTask(payload: { targetThreadDisplayName: payload.targetThreadDisplayName, targetCodexThreadRef: payload.targetCodexThreadRef, targetCodexFolderRef: payload.targetCodexFolderRef, + orchestrationBackendId: payload.orchestrationBackendId, + orchestrationBackendLabel: payload.orchestrationBackendLabel, status: "queued", requestedAt: nowIso(), }; @@ -4961,6 +5142,10 @@ export async function createDispatchPlan(input: { requestedBy: string; summary?: string; targets: DispatchPlanTarget[]; + requestedOrchestrationBackendId?: OrchestrationBackendOverride; + orchestrationBackendId?: OrchestrationBackendId; + orchestrationBackendLabel?: string; + orchestrationFallbackReason?: string; }) { return mutateState((state) => { return upsertDispatchPlanInState(state, input); @@ -4975,12 +5160,22 @@ function upsertDispatchPlanInState( requestedBy: string; summary?: string; targets: DispatchPlanTarget[]; + requestedOrchestrationBackendId?: OrchestrationBackendOverride; + orchestrationBackendId?: OrchestrationBackendId; + orchestrationBackendLabel?: string; + orchestrationFallbackReason?: string; }, ) { const groupProjectId = input.groupProjectId.trim(); const requestMessageId = input.requestMessageId.trim(); const requestedBy = input.requestedBy.trim(); const summary = input.summary?.trim() ?? ""; + const requestedOrchestrationBackendId = input.requestedOrchestrationBackendId; + const orchestrationBackendId = input.orchestrationBackendId ?? "boss-native-orchestrator"; + const orchestrationBackendLabel = + trimToDefined(input.orchestrationBackendLabel) ?? + (orchestrationBackendId === "omx-team" ? "OMX Team Runtime" : "Boss Native Orchestrator"); + const orchestrationFallbackReason = trimToDefined(input.orchestrationFallbackReason); if (!groupProjectId) throw new Error("DISPATCH_PLAN_GROUP_PROJECT_REQUIRED"); if (!requestMessageId) throw new Error("DISPATCH_PLAN_REQUEST_MESSAGE_REQUIRED"); @@ -4997,7 +5192,11 @@ function upsertDispatchPlanInState( const payloadMatches = existing.requestedBy === requestedBy && existing.summary === summary && - sameDispatchPlanTargets(existing.targets, validatedTargets); + sameDispatchPlanTargets(existing.targets, validatedTargets) && + existing.requestedOrchestrationBackendId === requestedOrchestrationBackendId && + existing.orchestrationBackendId === orchestrationBackendId && + existing.orchestrationBackendLabel === orchestrationBackendLabel && + existing.orchestrationFallbackReason === orchestrationFallbackReason; if (!payloadMatches) { throw new Error("DISPATCH_PLAN_RETRY_MISMATCH"); } @@ -5015,6 +5214,10 @@ function upsertDispatchPlanInState( status: "pending_user_confirmation", targets: validatedTargets, summary, + requestedOrchestrationBackendId, + orchestrationBackendId, + orchestrationBackendLabel, + orchestrationFallbackReason, createdAt: nowIso(), }; state.dispatchPlans.unshift(plan); @@ -5168,6 +5371,10 @@ export async function createDispatchExecutionsFromPlan(input: { if (!sameStringSet(existingTargetIds, canonicalTargetProjectIds)) { throw new Error("DISPATCH_EXECUTION_SET_MISMATCH"); } + for (const execution of existingExecutions) { + execution.orchestrationBackendId = execution.orchestrationBackendId ?? plan.orchestrationBackendId; + execution.orchestrationBackendLabel = execution.orchestrationBackendLabel ?? plan.orchestrationBackendLabel; + } if (plan.status !== "dispatched") { plan.status = "dispatched"; } @@ -5192,6 +5399,8 @@ export async function createDispatchExecutionsFromPlan(input: { targetProjectId: target.projectId, targetThreadId: target.threadId, deviceId: target.deviceId, + orchestrationBackendId: plan.orchestrationBackendId, + orchestrationBackendLabel: plan.orchestrationBackendLabel, status: "queued", createdAt, }; @@ -5267,6 +5476,8 @@ function ensureDispatchExecutionTaskInState( existing.targetThreadDisplayName = existing.targetThreadDisplayName ?? target.threadDisplayName; existing.targetCodexThreadRef = existing.targetCodexThreadRef ?? target.codexThreadRef; existing.targetCodexFolderRef = existing.targetCodexFolderRef ?? target.codexFolderRef; + existing.orchestrationBackendId = existing.orchestrationBackendId ?? execution.orchestrationBackendId; + existing.orchestrationBackendLabel = existing.orchestrationBackendLabel ?? execution.orchestrationBackendLabel; existing.executionPrompt = existing.executionPrompt || buildDispatchExecutionPrompt({ @@ -5299,6 +5510,8 @@ function ensureDispatchExecutionTaskInState( targetThreadDisplayName: target.threadDisplayName, targetCodexThreadRef: target.codexThreadRef, targetCodexFolderRef: target.codexFolderRef, + orchestrationBackendId: execution.orchestrationBackendId, + orchestrationBackendLabel: execution.orchestrationBackendLabel, status: "queued", requestedAt: nowIso(), }; @@ -5346,6 +5559,10 @@ export async function confirmDispatchPlanAndCreateExecutions(input: { if (!sameStringSet(existingTargetIds, canonicalTargetProjectIds)) { throw new Error("DISPATCH_EXECUTION_SET_MISMATCH"); } + for (const execution of existingExecutions) { + execution.orchestrationBackendId = execution.orchestrationBackendId ?? plan.orchestrationBackendId; + execution.orchestrationBackendLabel = execution.orchestrationBackendLabel ?? plan.orchestrationBackendLabel; + } if (plan.status !== "dispatched") { plan.status = "dispatched"; } @@ -5368,6 +5585,8 @@ export async function confirmDispatchPlanAndCreateExecutions(input: { targetProjectId: target.projectId, targetThreadId: target.threadId, deviceId: target.deviceId, + orchestrationBackendId: plan.orchestrationBackendId, + orchestrationBackendLabel: plan.orchestrationBackendLabel, status: "queued", createdAt, }; @@ -5684,6 +5903,10 @@ export async function completeMasterAgentTask(payload: { dispatchPlan?: { summary?: string; targets: DispatchPlanTarget[]; + requestedOrchestrationBackendId?: OrchestrationBackendOverride; + orchestrationBackendId?: OrchestrationBackendId; + orchestrationBackendLabel?: string; + orchestrationFallbackReason?: string; }; }) { const result = await mutateState((state) => { @@ -5776,6 +5999,10 @@ export async function completeMasterAgentTask(payload: { requestedBy: task.requestedByAccount, summary: payload.dispatchPlan.summary, targets: payload.dispatchPlan.targets, + requestedOrchestrationBackendId: payload.dispatchPlan.requestedOrchestrationBackendId, + orchestrationBackendId: payload.dispatchPlan.orchestrationBackendId, + orchestrationBackendLabel: payload.dispatchPlan.orchestrationBackendLabel, + orchestrationFallbackReason: payload.dispatchPlan.orchestrationFallbackReason, }); } } else if (task.taskType === "device_import_resolution") { diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index 429560b..408c8ad 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -34,7 +34,9 @@ import { createClawBackend, getClawBackendSelectionState, } from "@/lib/execution/backends/claw-backend"; +import { getOmxTeamBackendSelectionState } from "@/lib/execution/backends/omx-team-backend"; import { listExecutionBackendChoices, selectExecutionBackend } from "@/lib/execution/backend-selector"; +import { selectOrchestrationBackend } from "@/lib/execution/orchestration-backend-selector"; import { resolveRuntimeRelevantMemories } from "@/lib/execution/memory-resolver"; import type { RelevantMemory } from "@/lib/execution/memory-resolver"; import { buildExecutionPrompt } from "@/lib/execution/prompt-assembler"; @@ -1223,6 +1225,25 @@ type GroupDispatchRecommendationResult = error: string; }; +async function resolveGroupOrchestrationBackend(project: Project) { + const requestedBackendId = project.orchestrationBackendOverride; + const omx = await getOmxTeamBackendSelectionState(); + const selectedBackend = await selectOrchestrationBackend({ + requestedBackendId, + omx, + }); + const description = await selectedBackend.describe(); + return { + requestedBackendId, + orchestrationBackendId: description.backendId, + orchestrationBackendLabel: description.label, + orchestrationFallbackReason: + requestedBackendId === "omx-team" && description.backendId !== "omx-team" + ? omx.availability.reasonLabel + : undefined, + }; +} + async function resolveGroupDispatchPlanTask(taskId: string): Promise { const task = await getMasterAgentTask(taskId); if (!task) { @@ -1246,6 +1267,7 @@ async function resolveGroupDispatchPlanTask(taskId: string): Promise { - return (await listOrchestrationBackendChoices(input))[0] ?? BOSS_NATIVE_ORCHESTRATOR; + const resolution = await resolveOrchestrationBackendSelection(input); + return resolution.currentBackendId === OMX_TEAM_BACKEND.backendId + ? OMX_TEAM_BACKEND + : BOSS_NATIVE_ORCHESTRATOR; } export async function listOrchestrationBackendChoices( @@ -60,4 +83,45 @@ export async function listOrchestrationBackendChoices( return ordered; } +export async function listOrchestrationBackendChoiceViews( + input: OrchestrationBackendSelectionInput = {}, +): Promise { + const requestedBackendId = getRequestedBackendId(input); + const omxSelectable = isSelectableBackend(OMX_TEAM_BACKEND.backendId, input); + const choices = await listOrchestrationBackendChoices(input); + return choices.map((backend) => ({ + backendId: backend.backendId, + label: labelForOrchestrationBackend(backend.backendId), + selectable: backend.backendId === OMX_TEAM_BACKEND.backendId ? omxSelectable : true, + current: backend.backendId === requestedBackendId && (!omxSelectable || backend.backendId === requestedBackendId) + ? true + : backend.backendId === BOSS_NATIVE_ORCHESTRATOR.backendId && requestedBackendId !== OMX_TEAM_BACKEND.backendId, + })); +} + +export async function resolveOrchestrationBackendSelection( + input: OrchestrationBackendSelectionInput = {}, +): Promise { + const requestedBackendId = getRequestedBackendId(input); + const omxAvailability = input.omx?.availability; + const omxSelectable = input.omx?.selectable ?? false; + const omxRequested = requestedBackendId === OMX_TEAM_BACKEND.backendId; + const currentBackendId = + omxRequested && omxSelectable ? OMX_TEAM_BACKEND.backendId : BOSS_NATIVE_ORCHESTRATOR.backendId; + return { + requestedBackendId, + currentBackendId, + resolvedAt: new Date().toISOString(), + ...(omxAvailability ? { omxAvailability } : {}), + ...(omxRequested && !omxSelectable + ? { + fallbackReason: "omx-team unavailable", + fallbackReasonLabel: + omxAvailability?.reasonLabel ?? + "OMX Team Runtime 当前不可用,已自动回退到 Boss Native Orchestrator。", + } + : {}), + }; +} + export const selectOrchestrationBackendForTesting = selectOrchestrationBackend; diff --git a/src/lib/execution/orchestration-backend.ts b/src/lib/execution/orchestration-backend.ts index aca6412..2a63f11 100644 --- a/src/lib/execution/orchestration-backend.ts +++ b/src/lib/execution/orchestration-backend.ts @@ -1,4 +1,34 @@ +import type { OmxTeamBackendAvailability } from "@/lib/execution/backends/omx-team-config"; + +export type OrchestrationBackendId = "boss-native-orchestrator" | "omx-team"; + export interface OrchestrationBackend { - backendId: string; - describe(): Promise<{ backendId: string; label: string }>; + backendId: OrchestrationBackendId; + describe(): Promise<{ backendId: OrchestrationBackendId; label: string }>; +} + +export interface OrchestrationBackendChoiceView { + backendId: OrchestrationBackendId; + label: string; + selectable: boolean; + current: boolean; +} + +export interface OrchestrationBackendSelectionState { + requestedBackendId: OrchestrationBackendId; + currentBackendId: OrchestrationBackendId; + resolvedAt: string; + fallbackReason?: string; + fallbackReasonLabel?: string; + omxAvailability?: OmxTeamBackendAvailability; +} + +export function normalizeOrchestrationBackendId( + backendId?: string | null, +): OrchestrationBackendId { + return backendId?.trim() === "omx-team" ? "omx-team" : "boss-native-orchestrator"; +} + +export function labelForOrchestrationBackend(backendId: OrchestrationBackendId) { + return backendId === "omx-team" ? "OMX Team Runtime" : "Boss Native Orchestrator"; } diff --git a/tests/dispatch-plan-confirmation.test.ts b/tests/dispatch-plan-confirmation.test.ts index f554250..6f29d00 100644 --- a/tests/dispatch-plan-confirmation.test.ts +++ b/tests/dispatch-plan-confirmation.test.ts @@ -151,9 +151,18 @@ async function createDispatchPlanForTest() { ); assert.equal(response.status, 200); const payload = (await response.json()) as { - dispatchPlan: { planId: string; targets: Array<{ projectId: string }> } | null; + dispatchPlan: + | { + planId: string; + targets: Array<{ projectId: string }>; + orchestrationBackendId?: string; + orchestrationBackendLabel?: string; + } + | null; }; assert.ok(payload.dispatchPlan, "expected seeded dispatch plan"); + assert.equal(payload.dispatchPlan?.orchestrationBackendId, "boss-native-orchestrator"); + assert.equal(payload.dispatchPlan?.orchestrationBackendLabel, "Boss Native Orchestrator"); return { groupProject, dispatchPlan: payload.dispatchPlan }; } @@ -195,8 +204,20 @@ test("POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm confirms const payload = (await response.json()) as { ok: boolean; - plan: { planId: string; status: string; confirmedTargetProjectIds: string[] }; - executions: Array<{ planId: string; targetProjectId: string; status: string }>; + plan: { + planId: string; + status: string; + confirmedTargetProjectIds: string[]; + orchestrationBackendId?: string; + orchestrationBackendLabel?: string; + }; + executions: Array<{ + planId: string; + targetProjectId: string; + status: string; + orchestrationBackendId?: string; + orchestrationBackendLabel?: string; + }>; notice: { kind: string; body: string } | null; collaborationGate: { isGroup: boolean; @@ -231,6 +252,23 @@ test("POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm confirms message.body.includes("已确认下发到 1 个线程"), ); assert.ok(notice, "expected a master-agent notice in the group chat after confirmation"); + const confirmedPlan = nextState.dispatchPlans.find((plan) => plan.planId === dispatchPlan.planId); + assert.ok(confirmedPlan, "expected confirmed dispatch plan in state"); + assert.equal(confirmedPlan?.orchestrationBackendId, "boss-native-orchestrator"); + assert.equal(confirmedPlan?.orchestrationBackendLabel, "Boss Native Orchestrator"); + const createdExecution = nextState.dispatchExecutions.find((item) => item.planId === dispatchPlan.planId); + assert.ok(createdExecution, "expected dispatch execution in state"); + assert.equal(createdExecution?.orchestrationBackendId, "boss-native-orchestrator"); + assert.equal(createdExecution?.orchestrationBackendLabel, "Boss Native Orchestrator"); + const executionTask = nextState.masterAgentTasks.find( + (task) => + task.taskType === "dispatch_execution" && + task.projectId === groupProject.id && + task.targetProjectId === approvedTargetProjectId, + ); + assert.ok(executionTask, "expected queued dispatch execution task"); + assert.equal(executionTask?.orchestrationBackendId, "boss-native-orchestrator"); + assert.equal(executionTask?.orchestrationBackendLabel, "Boss Native Orchestrator"); }); test("confirming a dispatch plan marks approval_required groups as approved", async () => { diff --git a/tests/group-orchestration-backend.test.ts b/tests/group-orchestration-backend.test.ts new file mode 100644 index 0000000..a8b0b54 --- /dev/null +++ b/tests/group-orchestration-backend.test.ts @@ -0,0 +1,270 @@ +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"; +import { NextRequest } from "next/server"; + +let runtimeRoot = ""; +let getRoute: (typeof import("../src/app/api/v1/projects/[projectId]/orchestration-backend/route"))["GET"]; +let patchRoute: (typeof import("../src/app/api/v1/projects/[projectId]/orchestration-backend/route"))["PATCH"]; +let postMessageRoute: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"]; +let confirmDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route"))["POST"]; +let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; +let createProjectGroupChat: (typeof import("../src/lib/boss-data"))["createProjectGroupChat"]; +let isDispatchableThreadProject: (typeof import("../src/lib/boss-data"))["isDispatchableThreadProject"]; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let writeState: (typeof import("../src/lib/boss-data"))["writeState"]; +let AUTH_SESSION_COOKIE = ""; +let baseState: Awaited>; + +const originalEnv = { + BOSS_OMX_ENABLED: process.env.BOSS_OMX_ENABLED, + BOSS_OMX_COMMAND: process.env.BOSS_OMX_COMMAND, + BOSS_OMX_ARGS: process.env.BOSS_OMX_ARGS, + BOSS_OMX_WORKDIR: process.env.BOSS_OMX_WORKDIR, + BOSS_OMX_TIMEOUT_MS: process.env.BOSS_OMX_TIMEOUT_MS, +}; + +async function setup() { + if (runtimeRoot) return; + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-omx-route-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [orchestrationRoute, messageRoute, confirmRoute, data, auth] = await Promise.all([ + import("../src/app/api/v1/projects/[projectId]/orchestration-backend/route.ts"), + import("../src/app/api/v1/projects/[projectId]/messages/route.ts"), + import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts"), + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-auth.ts"), + ]); + + getRoute = orchestrationRoute.GET; + patchRoute = orchestrationRoute.PATCH; + postMessageRoute = messageRoute.POST; + confirmDispatchPlanRoute = confirmRoute.POST; + createAuthSession = data.createAuthSession; + createProjectGroupChat = data.createProjectGroupChat; + isDispatchableThreadProject = data.isDispatchableThreadProject; + readState = data.readState; + writeState = data.writeState; + baseState = structuredClone(await readState()); + AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; +} + +async function authedRequest(url: string, method: "GET" | "PATCH" | "POST", body?: unknown) { + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + return new NextRequest(url, { + method, + headers: { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + body: body ? JSON.stringify(body) : undefined, + }); +} + +async function ensureTwoSingleThreadProjects() { + const state = await readState(); + const singles = state.projects.filter((project) => isDispatchableThreadProject(project)); + if (singles.length >= 2) { + return singles; + } + assert.ok(singles[0], "expected at least one dispatchable project"); + const seed = singles[0]; + const clone = { + ...seed, + id: "omx-thread-b", + name: "Boss OMX 副线程", + threadMeta: { + ...seed.threadMeta, + projectId: "omx-thread-b", + threadId: "thread-omx-b", + threadDisplayName: "OMX 副线程", + codexThreadRef: "thread-omx-b", + codexFolderRef: "/Users/kris/code/boss", + }, + messages: [ + { + id: "msg-omx-seed", + sender: "device" as const, + senderLabel: "Mac Studio / Codex", + body: "等待群聊下发。", + sentAt: "2026-04-03T10:00:00+08:00", + kind: "text" as const, + }, + ], + }; + await writeState({ + ...state, + projects: [...state.projects, clone], + }); + const nextState = await readState(); + return nextState.projects.filter((project) => isDispatchableThreadProject(project)); +} + +function configureOmxAvailable() { + process.env.BOSS_OMX_ENABLED = "true"; + process.env.BOSS_OMX_COMMAND = process.execPath; + process.env.BOSS_OMX_ARGS = "/Users/kris/code/boss/scripts/omx-team-smoke.mjs"; + process.env.BOSS_OMX_WORKDIR = "/Users/kris/code/boss"; + process.env.BOSS_OMX_TIMEOUT_MS = "45000"; +} + +function restoreOmxEnv() { + process.env.BOSS_OMX_ENABLED = originalEnv.BOSS_OMX_ENABLED; + process.env.BOSS_OMX_COMMAND = originalEnv.BOSS_OMX_COMMAND; + process.env.BOSS_OMX_ARGS = originalEnv.BOSS_OMX_ARGS; + process.env.BOSS_OMX_WORKDIR = originalEnv.BOSS_OMX_WORKDIR; + process.env.BOSS_OMX_TIMEOUT_MS = originalEnv.BOSS_OMX_TIMEOUT_MS; +} + +test.beforeEach(async () => { + await setup(); + restoreOmxEnv(); + await writeState(structuredClone(baseState)); +}); + +test.after(async () => { + restoreOmxEnv(); + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +test("GET orchestration backend returns null requested backend for default group chats", async () => { + const singles = await ensureTwoSingleThreadProjects(); + const group = await createProjectGroupChat({ + sourceProjectId: singles[0].id, + memberProjectIds: [singles[1].id], + createdBy: "17600003315", + }); + + const response = await getRoute( + await authedRequest(`http://127.0.0.1:3000/api/v1/projects/${group.id}/orchestration-backend`, "GET"), + { params: Promise.resolve({ projectId: group.id }) }, + ); + assert.equal(response.status, 200); + const payload = (await response.json()) as { + ok: boolean; + requestedBackendId: string | null; + currentBackendId: string; + availableChoices: Array<{ backendId: string; current: boolean }>; + }; + + assert.equal(payload.ok, true); + assert.equal(payload.requestedBackendId, null); + assert.equal(payload.currentBackendId, "boss-native-orchestrator"); + assert.equal(payload.availableChoices[0]?.backendId, "boss-native-orchestrator"); + assert.equal(payload.availableChoices[0]?.current, true); +}); + +test("PATCH orchestration backend rejects omx when runtime is unavailable", async () => { + const singles = await ensureTwoSingleThreadProjects(); + const group = await createProjectGroupChat({ + sourceProjectId: singles[0].id, + memberProjectIds: [singles[1].id], + createdBy: "17600003315", + }); + + const response = await patchRoute( + await authedRequest( + `http://127.0.0.1:3000/api/v1/projects/${group.id}/orchestration-backend`, + "PATCH", + { requestedBackendId: "omx-team" }, + ), + { params: Promise.resolve({ projectId: group.id }) }, + ); + + assert.equal(response.status, 200); + const payload = (await response.json()) as { + ok: boolean; + requestedBackendId: string; + currentBackendId: string; + omxAvailability: { selectable: boolean; reasonLabel: string }; + }; + assert.equal(payload.ok, true); + assert.equal(payload.requestedBackendId, "omx-team"); + assert.equal(payload.currentBackendId, "boss-native-orchestrator"); + assert.equal(payload.omxAvailability.selectable, false); + assert.equal(payload.omxAvailability.reasonLabel, "OMX Team Runtime 当前未启用。"); +}); + +test("group dispatch plans and executions carry omx backend when selected and available", async () => { + configureOmxAvailable(); + const singles = await ensureTwoSingleThreadProjects(); + const group = await createProjectGroupChat({ + sourceProjectId: singles[0].id, + memberProjectIds: [singles[1].id], + createdBy: "17600003315", + }); + + const saveResponse = await patchRoute( + await authedRequest( + `http://127.0.0.1:3000/api/v1/projects/${group.id}/orchestration-backend`, + "PATCH", + { requestedBackendId: "omx-team" }, + ), + { params: Promise.resolve({ projectId: group.id }) }, + ); + assert.equal(saveResponse.status, 200); + + const postResponse = await postMessageRoute( + await authedRequest( + `http://127.0.0.1:3000/api/v1/projects/${group.id}/messages`, + "POST", + { body: "请大家汇总一下今天的 OMX 联调阻塞点" }, + ), + { params: Promise.resolve({ projectId: group.id }) }, + ); + assert.equal(postResponse.status, 200); + const postPayload = (await postResponse.json()) as { + ok: boolean; + dispatchPlan: null | { + planId: string; + orchestrationBackendId?: string; + orchestrationBackendLabel?: string; + }; + }; + assert.equal(postPayload.ok, true); + assert.ok(postPayload.dispatchPlan, "expected dispatch plan"); + assert.equal(postPayload.dispatchPlan?.orchestrationBackendId, "omx-team"); + assert.equal(postPayload.dispatchPlan?.orchestrationBackendLabel, "OMX Team Runtime"); + + const confirmResponse = await confirmDispatchPlanRoute( + await authedRequest( + `http://127.0.0.1:3000/api/v1/projects/${group.id}/dispatch-plans/${postPayload.dispatchPlan?.planId}/confirm`, + "POST", + { approvedTargetProjectIds: [singles[0].id, singles[1].id] }, + ), + { params: Promise.resolve({ projectId: group.id, planId: postPayload.dispatchPlan?.planId ?? "" }) }, + ); + assert.equal(confirmResponse.status, 200); + const confirmPayload = (await confirmResponse.json()) as { + ok: boolean; + plan: { orchestrationBackendId?: string }; + executions: Array<{ orchestrationBackendId?: string; orchestrationBackendLabel?: string }>; + }; + assert.equal(confirmPayload.ok, true); + assert.equal(confirmPayload.plan.orchestrationBackendId, "omx-team"); + assert.ok(confirmPayload.executions.length > 0); + assert.ok(confirmPayload.executions.every((item) => item.orchestrationBackendId === "omx-team")); + assert.ok(confirmPayload.executions.every((item) => item.orchestrationBackendLabel === "OMX Team Runtime")); + + const nextState = await readState(); + const queuedTasks = nextState.masterAgentTasks.filter( + (task) => task.taskType === "dispatch_execution" && task.projectId === group.id, + ); + assert.ok( + queuedTasks.some((task) => task.orchestrationBackendId === "omx-team"), + "expected dispatch execution tasks to carry omx metadata", + ); +}); diff --git a/tests/project-orchestration-backend-route.test.ts b/tests/project-orchestration-backend-route.test.ts new file mode 100644 index 0000000..220514d --- /dev/null +++ b/tests/project-orchestration-backend-route.test.ts @@ -0,0 +1,200 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { NextRequest } from "next/server"; + +let runtimeRoot = ""; +let AUTH_SESSION_COOKIE = ""; +let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let orchestrationBackendRoute: typeof import("../src/app/api/v1/projects/[projectId]/orchestration-backend/route"); + +async function setup() { + if (runtimeRoot) return; + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-orchestration-backend-route-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [data, auth, routeModule] = await Promise.all([ + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-auth.ts"), + import("../src/app/api/v1/projects/[projectId]/orchestration-backend/route.ts"), + ]); + + createAuthSession = data.createAuthSession; + readState = data.readState; + AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; + orchestrationBackendRoute = routeModule; +} + +async function createAuthedHeaders() { + await setup(); + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + return { + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + "content-type": "application/json", + }; +} + +function snapshotOmxEnv() { + return { + BOSS_OMX_ENABLED: process.env.BOSS_OMX_ENABLED, + BOSS_OMX_COMMAND: process.env.BOSS_OMX_COMMAND, + BOSS_OMX_ARGS: process.env.BOSS_OMX_ARGS, + BOSS_OMX_WORKDIR: process.env.BOSS_OMX_WORKDIR, + }; +} + +function restoreOmxEnv(snapshot: ReturnType) { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +test("GET /api/v1/projects/[projectId]/orchestration-backend returns current choice and OMX availability", async () => { + await setup(); + const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-orchestration-backend-omx-")); + const scriptPath = path.join(tempDir, "omx-team-smoke.mjs"); + await writeFile(scriptPath, "console.log('ok');\n", "utf8"); + const previousEnv = snapshotOmxEnv(); + process.env.BOSS_OMX_ENABLED = "true"; + process.env.BOSS_OMX_COMMAND = process.execPath; + process.env.BOSS_OMX_ARGS = scriptPath; + process.env.BOSS_OMX_WORKDIR = tempDir; + + try { + const response = await orchestrationBackendRoute.GET( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/audit-collab/orchestration-backend", { + method: "GET", + headers: await createAuthedHeaders(), + }), + { params: Promise.resolve({ projectId: "audit-collab" }) }, + ); + + assert.equal(response.status, 200); + const payload = (await response.json()) as { + ok: boolean; + projectId: string; + currentBackendId: string; + requestedBackendId: string | null; + availableChoices: Array<{ backendId: string; selectable: boolean; current: boolean }>; + omxAvailability: { selectable: boolean; reason: string }; + }; + + assert.equal(payload.ok, true); + assert.equal(payload.projectId, "audit-collab"); + assert.equal(payload.currentBackendId, "boss-native-orchestrator"); + assert.equal(payload.requestedBackendId, null); + assert.deepEqual( + payload.availableChoices.map((choice) => choice.backendId), + ["boss-native-orchestrator", "omx-team"], + ); + assert.equal(payload.availableChoices[0]?.current, true); + assert.equal(payload.availableChoices[1]?.selectable, true); + assert.equal(payload.omxAvailability.selectable, true); + assert.equal(payload.omxAvailability.reason, "ready"); + } finally { + restoreOmxEnv(previousEnv); + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("PATCH /api/v1/projects/[projectId]/orchestration-backend persists OMX selection when selectable", async () => { + await setup(); + const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-orchestration-backend-omx-")); + const scriptPath = path.join(tempDir, "omx-team-smoke.mjs"); + await writeFile(scriptPath, "console.log('ok');\n", "utf8"); + const previousEnv = snapshotOmxEnv(); + process.env.BOSS_OMX_ENABLED = "true"; + process.env.BOSS_OMX_COMMAND = process.execPath; + process.env.BOSS_OMX_ARGS = scriptPath; + process.env.BOSS_OMX_WORKDIR = tempDir; + + try { + const response = await orchestrationBackendRoute.PATCH( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/audit-collab/orchestration-backend", { + method: "PATCH", + headers: await createAuthedHeaders(), + body: JSON.stringify({ orchestrationBackendOverride: "omx-team" }), + }), + { params: Promise.resolve({ projectId: "audit-collab" }) }, + ); + + assert.equal(response.status, 200); + const payload = (await response.json()) as { + ok: boolean; + currentBackendId: string; + requestedBackendId: string; + omxAvailability: { selectable: boolean }; + }; + assert.equal(payload.ok, true); + assert.equal(payload.currentBackendId, "omx-team"); + assert.equal(payload.requestedBackendId, "omx-team"); + assert.equal(payload.omxAvailability.selectable, true); + + const state = await readState(); + const project = state.projects.find((item) => item.id === "audit-collab"); + assert.equal(project?.orchestrationBackendOverride, "omx-team"); + } finally { + restoreOmxEnv(previousEnv); + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("PATCH /api/v1/projects/[projectId]/orchestration-backend falls back to native when OMX is unavailable", async () => { + await setup(); + const previousEnv = snapshotOmxEnv(); + delete process.env.BOSS_OMX_ENABLED; + delete process.env.BOSS_OMX_COMMAND; + delete process.env.BOSS_OMX_ARGS; + delete process.env.BOSS_OMX_WORKDIR; + + try { + const response = await orchestrationBackendRoute.PATCH( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/audit-collab/orchestration-backend", { + method: "PATCH", + headers: await createAuthedHeaders(), + body: JSON.stringify({ orchestrationBackendOverride: "omx-team" }), + }), + { params: Promise.resolve({ projectId: "audit-collab" }) }, + ); + + assert.equal(response.status, 200); + const payload = (await response.json()) as { + ok: boolean; + currentBackendId: string; + requestedBackendId: string; + omxAvailability: { selectable: boolean; reasonLabel: string }; + }; + assert.equal(payload.ok, true); + assert.equal(payload.requestedBackendId, "omx-team"); + assert.equal(payload.currentBackendId, "boss-native-orchestrator"); + assert.equal(payload.omxAvailability.selectable, false); + assert.equal(payload.omxAvailability.reasonLabel, "OMX Team Runtime 当前未启用。"); + + const state = await readState(); + const project = state.projects.find((item) => item.id === "audit-collab"); + assert.equal(project?.orchestrationBackendOverride, "omx-team"); + } finally { + restoreOmxEnv(previousEnv); + } +});