feat: add omx orchestration backend selection

This commit is contained in:
kris
2026-04-03 03:17:12 +08:00
parent 60f5e2d7d6
commit ec45bed59f
18 changed files with 1993 additions and 20 deletions

View File

@@ -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 / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口

View File

@@ -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"
}

View File

@@ -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);
}

View File

@@ -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()) {

View File

@@ -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<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) {}
}
}

View File

@@ -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<String, String> 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<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) {}
}
private static final class DirectExecutorService extends AbstractExecutorService {
private boolean shutdown;
@Override
public void shutdown() {
shutdown = true;
}
@Override
public List<Runnable> 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();
}
}
}

View File

@@ -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`
- 用途:读取单线程会话的线程归属信息,或群聊会话的成员线程列表

View File

@@ -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 队列

View File

@@ -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 },
);
}
}

View File

@@ -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({
<div className="pt-3">
<ProjectHeaderActions projectId={detail.project.id} />
</div>
{detail.project.isGroup && orchestrationBackendState ? (
<div className="mt-3">
<ProjectOrchestrationBackendCard
projectId={detail.project.id}
initialState={orchestrationBackendState}
/>
</div>
) : null}
<div className="mt-4 space-y-3">
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[14px] font-semibold text-[#111111]"> Agent </div>

View File

@@ -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<OrchestrationBackendId | null>(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 (
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[14px] font-semibold text-[#111111]"></div>
<div className="mt-1 text-[12px] leading-5 text-[#57606A]">
{state.currentBackendLabel}
<br />
{state.requestedBackendLabel}
</div>
</div>
<div
className={clsx(
"rounded-full px-3 py-1 text-[11px] font-semibold",
fallbackActive || !state.omxAvailability.selectable
? "bg-[#FFF7E6] text-[#D46B08]"
: "bg-[#EAF7F0] text-[#215B39]",
)}
>
{availabilityCopy.badge}
</div>
</div>
<div className="mt-3 grid gap-2">
{state.availableChoices.map((choice) => {
const active = choice.current;
const selectable = choice.selectable && savingBackendId !== choice.backendId;
return (
<button
key={choice.backendId}
type="button"
onClick={() => void saveBackend(choice.backendId)}
disabled={!selectable}
className={clsx(
"flex items-center justify-between rounded-2xl border px-4 py-3 text-left",
active ? "border-[#07C160] bg-[#F5FFF8]" : "border-[#E5E5EA] bg-[#F7F8FA]",
!choice.selectable ? "opacity-70" : "",
)}
>
<div>
<div className="text-[14px] font-semibold text-[#111111]">
{orchestrationBackendChoiceLabel(choice)}
</div>
<div className="mt-1 text-[12px] leading-5 text-[#57606A]">{choice.label}</div>
</div>
<div className="text-right text-[11px] text-[#8C8C8C]">
<div>{active ? "当前" : "切换"}</div>
{!choice.selectable ? <div></div> : null}
</div>
</button>
);
})}
</div>
<div
className={clsx(
"mt-3 rounded-2xl px-4 py-3 text-[12px] leading-6",
state.omxAvailability.selectable
? "bg-[#EAF7F0] text-[#215B39]"
: "bg-[#FFF7E6] text-[#8D5D00]",
)}
>
{availabilityCopy.summary}
</div>
{message ? (
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] text-[#57606A]">
{message}
</div>
) : null}
</div>
);
}
function masterIdentityPillClasses(role: MasterIdentitySummary["role"]) {
switch (role) {
case "primary":

View File

@@ -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<DispatchPlan>, 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<Project>, 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<BossState> | 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<ProjectOrchestrationBackendState> {
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<ProjectOrchestrationBackendState | null> {
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") {

View File

@@ -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<GroupDispatchRecommendationResult> {
const task = await getMasterAgentTask(taskId);
if (!task) {
@@ -1246,6 +1267,7 @@ async function resolveGroupDispatchPlanTask(taskId: string): Promise<GroupDispat
if (targets.length === 0) {
throw new Error("GROUP_DISPATCH_TARGETS_REQUIRED");
}
const orchestrationBackend = await resolveGroupOrchestrationBackend(project);
const completedTask = await completeMasterAgentTask({
taskId: task.taskId,
@@ -1254,6 +1276,10 @@ async function resolveGroupDispatchPlanTask(taskId: string): Promise<GroupDispat
dispatchPlan: {
summary: summarizeGroupDispatchPlan(task.requestText, targets),
targets,
requestedOrchestrationBackendId: orchestrationBackend.requestedBackendId,
orchestrationBackendId: orchestrationBackend.orchestrationBackendId,
orchestrationBackendLabel: orchestrationBackend.orchestrationBackendLabel,
orchestrationFallbackReason: orchestrationBackend.orchestrationFallbackReason,
},
});
@@ -1296,6 +1322,8 @@ export async function queueGroupDispatchPlan(params: {
throw new Error("PROJECT_NOT_GROUP_CHAT");
}
const orchestrationBackend = await resolveGroupOrchestrationBackend(project);
const task = await queueMasterAgentTask({
projectId: project.id,
taskType: "group_dispatch_plan",
@@ -1305,6 +1333,8 @@ export async function queueGroupDispatchPlan(params: {
requestedBy: params.requestedBy,
requestedByAccount: params.requestedBy,
deviceId: state.user.boundDeviceId || "mac-studio",
orchestrationBackendId: orchestrationBackend.orchestrationBackendId,
orchestrationBackendLabel: orchestrationBackend.orchestrationBackendLabel,
});
return resolveGroupDispatchPlanTask(task.taskId);

View File

@@ -3,7 +3,13 @@ import {
OMX_TEAM_BACKEND,
type OmxTeamBackendSelectionState,
} from "@/lib/execution/backends/omx-team-backend";
import type { OrchestrationBackend } from "@/lib/execution/orchestration-backend";
import {
labelForOrchestrationBackend,
normalizeOrchestrationBackendId,
type OrchestrationBackend,
type OrchestrationBackendChoiceView,
type OrchestrationBackendSelectionState,
} from "@/lib/execution/orchestration-backend";
export interface OrchestrationBackendSelectionInput {
requestedBackendId?: string;
@@ -24,10 +30,27 @@ function isReadyBackend(
return true;
}
function getRequestedBackendId(input: OrchestrationBackendSelectionInput) {
return normalizeOrchestrationBackendId(input.requestedBackendId);
}
function isSelectableBackend(
backendId: string,
input: OrchestrationBackendSelectionInput,
) {
if (backendId === OMX_TEAM_BACKEND.backendId) {
return input.omx?.selectable ?? false;
}
return true;
}
export async function selectOrchestrationBackend(
input: OrchestrationBackendSelectionInput = {},
): Promise<OrchestrationBackendChoice> {
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<OrchestrationBackendChoiceView[]> {
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<OrchestrationBackendSelectionState> {
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;

View File

@@ -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";
}

View File

@@ -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 () => {

View File

@@ -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<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
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",
);
});

View File

@@ -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<typeof snapshotOmxEnv>) {
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);
}
});