feat: add omx orchestration backend selection
This commit is contained in:
@@ -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 / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
- 用途:读取单线程会话的线程归属信息,或群聊会话的成员线程列表
|
||||
|
||||
@@ -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 队列
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
270
tests/group-orchestration-backend.test.ts
Normal file
270
tests/group-orchestration-backend.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
200
tests/project-orchestration-backend-route.test.ts
Normal file
200
tests/project-orchestration-backend-route.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user