fix: restore master agent relay guidance

This commit is contained in:
kris
2026-03-30 17:42:21 +08:00
parent 5eb1246f02
commit 7c6101f22b
9 changed files with 312 additions and 22 deletions

View File

@@ -43,6 +43,7 @@
- `GET http://127.0.0.1:3000/api/v1/conversations` 正常
- `GET http://127.0.0.1:3000/api/v1/projects/master-agent` 正常,主 Agent 项目页已能显示最近 APP 日志
- `GET http://127.0.0.1:3000/api/v1/accounts` 正常,已返回主 GPT / 备用 GPT / API 容灾账号摘要
- `POST http://127.0.0.1:3000/api/v1/accounts/master-codex-primary/validate` 正常,已验证会明确提示“主 GPT 不在手机里直接登录”,并校验绑定设备在线状态
- `GET http://127.0.0.1:3000/api/v1/devices/mac-studio/skills` 正常,已返回本机同步 Skill 列表
- `POST http://127.0.0.1:3000/api/auth/login` 正常,会写入 `boss_session` Cookie
- `GET http://127.0.0.1:3000/api/auth/session` 正常
@@ -279,6 +280,8 @@ npm run aab:release
- `/api/v1/events` 已作为 SSE 出口使用,会话页、设备页、技能页和项目详情页会按事件自动刷新,不再只靠手动刷新
- 我的页新增 `技能` 入口,`/me/skills` 会按设备分组展示 Skill并支持一键复制调用语句
- 我的页新增 `AI 账号` 入口,`/me/ai-accounts` 会展示主 GPT / 备用 GPT / API 容灾,并明确主链路优先走已登录 `ChatGPT Plus / Codex``Master Codex Node`
- `AI 账号` 页面当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth主 GPT 的登录动作必须在绑定电脑上的 Codex / ChatGPT Plus 会话里完成,再回手机端点“测试连接 / 校验连接”
- `POST /api/v1/accounts/[accountId]/validate` 当前不再只看 `nodeId`;对 `master_codex_node` 会同时校验绑定设备是否在线,并在设备离线时返回明确的降级说明
- API 容灾当前不走服务器预置 Key而是由用户在 APP 的 `我的 > AI 账号` 中自行配置 `OpenAI API` 账号
- 设备页当前只展示已接入生产链路的设备,历史演示脏数据已经从正式设备视图、运维视图和审计视图中剔除
- 本机 `local-agent` 现在会直接从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 动态发现真实 Codex 线程,并在 heartbeat 里上报 `projectCandidates`
@@ -314,3 +317,4 @@ npm run aab:release
- 图片 / PDF / 文本默认自动进入主 Agent 附件分析;视频 / Office / 大文件默认手动触发
- 当前采用“极轻云 + 本地设备端”的路线,云端只承载 Web、轻 API 和状态文件
- 服务器侧主 Agent 对话能否返回真实大模型回复,依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;服务器本身不直接持有主 GPT 会话
- 原生 Android 当前对 `master-agent` 聊天消息已单独放宽读超时到 `65s`;不会再因为默认 `12s` 超时把“主 Agent 无响应”误判成对话失败

View File

@@ -53,7 +53,7 @@ public class AiAccountsActivity extends BossScreenActivity {
this,
"AI 账号",
"这里统一管理主 GPT、备用 GPT 与 API 容灾账号。",
"轻点条目可编辑,按钮可切换、校验或删除",
"主 GPT 的登录发生在绑定设备上的 Codex / ChatGPT Plus会在这里给登录指引",
null,
null
));
@@ -143,16 +143,44 @@ public class AiAccountsActivity extends BossScreenActivity {
activate.setEnabled(!account.optBoolean("isActive"));
activate.setOnClickListener(v -> activateAccount(account));
Button loginGuide = null;
if ("master_codex_node".equals(account.optString("provider"))) {
loginGuide = BossUi.buildMiniActionButton(this, "登录指引", false);
loginGuide.setOnClickListener(v -> showMasterNodeLoginGuide(account));
}
Button validate = BossUi.buildMiniActionButton(this, "校验连接", false);
validate.setOnClickListener(v -> validateAccount(account));
Button delete = BossUi.buildMiniActionButton(this, "删除账号", false);
delete.setOnClickListener(v -> confirmDeleteAccount(account));
card.addView(BossUi.buildInlineActionRow(this, activate, validate, delete));
card.addView(loginGuide == null
? BossUi.buildInlineActionRow(this, activate, validate, delete)
: BossUi.buildInlineActionRow(this, activate, loginGuide, validate, delete));
return card;
}
private void showMasterNodeLoginGuide(JSONObject account) {
String nodeLabel = account.optString("nodeLabel");
if (nodeLabel == null || nodeLabel.trim().isEmpty()) {
nodeLabel = account.optString("nodeId");
}
if (nodeLabel == null || nodeLabel.trim().isEmpty()) {
nodeLabel = "绑定设备";
}
String message = "主 GPT 不在手机里直接登录。\n\n"
+ "请到绑定设备 " + nodeLabel + " 上打开 Codex / ChatGPT Plus 会话完成登录。\n"
+ "登录完成后,回到这里点“校验连接”,确认主 Agent relay 已经接通。";
new AlertDialog.Builder(this)
.setTitle("主 GPT 登录指引")
.setMessage(message)
.setPositiveButton("知道了", null)
.show();
}
private void openAccountEditor(@Nullable JSONObject existing, @Nullable String apiKeyHint) {
final android.widget.EditText labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false);
final android.widget.EditText displayNameInput = BossUi.buildInput(this, "显示名称", false);
@@ -313,7 +341,7 @@ public class AiAccountsActivity extends BossScreenActivity {
BossApiClient.ApiResponse response = apiClient.validateAccount(account.optString("accountId"));
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage("账号校验成功");
showMessage(response.message());
reload();
});
} catch (Exception error) {

View File

@@ -27,6 +27,9 @@ import java.util.List;
import java.util.Map;
public class BossApiClient {
private static final int DEFAULT_CONNECT_TIMEOUT_MS = 12000;
private static final int DEFAULT_READ_TIMEOUT_MS = 12000;
private static final int MASTER_AGENT_READ_TIMEOUT_MS = 65000;
private static final String PREFS_NAME = "boss_native_client";
private static final String KEY_SESSION_COOKIE = "session_cookie";
private static final String KEY_RESTORE_TOKEN = "restore_token";
@@ -130,7 +133,14 @@ public class BossApiClient {
JSONObject payload = new JSONObject();
payload.put("body", body);
payload.put("kind", kind);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/messages", payload);
int readTimeoutMs = "master-agent".equals(projectId) ? MASTER_AGENT_READ_TIMEOUT_MS : DEFAULT_READ_TIMEOUT_MS;
return requestWithRestoreRaw(
"POST",
"/api/v1/projects/" + encode(projectId) + "/messages",
payload.toString(),
DEFAULT_CONNECT_TIMEOUT_MS,
readTimeoutMs
);
}
public ApiResponse uploadAttachment(
@@ -157,7 +167,7 @@ public class BossApiClient {
String sourceType
) throws IOException, JSONException {
HttpURLConnection connection = openConnection("/api/v1/projects/" + encode(projectId) + "/attachments");
prepareConnection(connection, "POST");
prepareConnection(connection, "POST", DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
connection.setDoOutput(true);
String boundary = "BossBoundary" + System.currentTimeMillis();
@@ -373,27 +383,61 @@ public class BossApiClient {
}
private ApiResponse requestWithRestore(String method, String path, JSONObject body) throws IOException, JSONException {
return requestWithRestoreRaw(method, path, body == null ? null : body.toString());
return requestWithRestoreRaw(
method,
path,
body == null ? null : body.toString(),
DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_READ_TIMEOUT_MS
);
}
private ApiResponse requestWithRestoreRaw(String method, String path, @Nullable String body) throws IOException, JSONException {
ApiResponse response = requestRaw(method, path, body, true);
return requestWithRestoreRaw(method, path, body, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
}
private ApiResponse requestWithRestoreRaw(
String method,
String path,
@Nullable String body,
int connectTimeoutMs,
int readTimeoutMs
) throws IOException, JSONException {
ApiResponse response = requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
if (response.statusCode == 401 && !getRestoreToken().isEmpty()) {
ApiResponse restored = restoreSession();
if (restored.ok()) {
return requestRaw(method, path, body, true);
return requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
}
}
return response;
}
private ApiResponse request(String method, String path, JSONObject body, boolean expectProtected) throws IOException, JSONException {
return requestRaw(method, path, body == null ? null : body.toString(), expectProtected);
return requestRaw(
method,
path,
body == null ? null : body.toString(),
expectProtected,
DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_READ_TIMEOUT_MS
);
}
private ApiResponse requestRaw(String method, String path, @Nullable String body, boolean expectProtected) throws IOException, JSONException {
return requestRaw(method, path, body, expectProtected, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
}
private ApiResponse requestRaw(
String method,
String path,
@Nullable String body,
boolean expectProtected,
int connectTimeoutMs,
int readTimeoutMs
) throws IOException, JSONException {
HttpURLConnection connection = openConnection(path);
prepareConnection(connection, method);
prepareConnection(connection, method, connectTimeoutMs, readTimeoutMs);
if (body != null) {
connection.setDoOutput(true);
@@ -411,10 +455,15 @@ public class BossApiClient {
return (HttpURLConnection) new URL(baseUrl + path).openConnection();
}
private void prepareConnection(HttpURLConnection connection, String method) throws IOException {
private void prepareConnection(
HttpURLConnection connection,
String method,
int connectTimeoutMs,
int readTimeoutMs
) throws IOException {
connection.setRequestMethod(method);
connection.setConnectTimeout(12000);
connection.setReadTimeout(12000);
connection.setConnectTimeout(connectTimeoutMs);
connection.setReadTimeout(readTimeoutMs);
connection.setUseCaches(false);
connection.setDoInput(true);
connection.setRequestProperty("Accept", "application/json");
@@ -500,7 +549,7 @@ public class BossApiClient {
boolean expectProtected
) throws IOException {
HttpURLConnection connection = openConnection("/api/v1/attachments/" + encode(attachmentId) + "/download");
prepareConnection(connection, "GET");
prepareConnection(connection, "GET", DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
int statusCode = connection.getResponseCode();
captureSessionCookie(connection.getHeaderFields());

View File

@@ -52,6 +52,20 @@ public class BossApiClientDispatchPlansTest {
assertEquals("{\"approvedTargetProjectIds\":[\"target-1\",\"target-2\"]}", connection.requestBody());
}
@Test
public void sendProjectMessageUsesExtendedReadTimeoutForMasterAgent() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/messages"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.sendProjectMessage("master-agent", "你好", "text");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/master-agent/messages", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(65000, connection.readTimeoutValue);
}
private static final class RecordingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
private String lastPath = "";
@@ -82,6 +96,8 @@ public class BossApiClientDispatchPlansTest {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
private String requestMethodValue = "GET";
private int connectTimeoutValue = 0;
private int readTimeoutValue = 0;
RecordingConnection(URL url) {
super(url);
@@ -108,6 +124,16 @@ public class BossApiClientDispatchPlansTest {
requestHeaders.put(key, value);
}
@Override
public void setConnectTimeout(int timeout) {
connectTimeoutValue = timeout;
}
@Override
public void setReadTimeout(int timeout) {
readTimeoutValue = timeout;
}
@Override
public String getRequestProperty(String key) {
return requestHeaders.get(key);

View File

@@ -430,10 +430,11 @@
- 用途:校验指定 AI 账号是否可用
- 当前行为:
- `master_codex_node`校验是否配置节点 ID并提示由 local-agent relay 执行
- `master_codex_node`明确提示“主 GPT 不在手机里直接登录”,并同时校验绑定设备是否在线;在线时返回 `ready`,离线时返回 `degraded`
- `openai_api`:实际调用模型返回探针结果
- 当前约束:
- `openai_api` 的容灾 Key 由用户在 APP 内配置,不走服务器默认预置
- 手机端当前没有直接的 ChatGPT OAuth 登录流程;主 GPT 必须先在绑定电脑上的 Codex / ChatGPT Plus 会话里完成登录
#### `POST /api/v1/projects/[projectId]/forwards`

View File

@@ -12,6 +12,7 @@
- 会话聚合接口:`http://127.0.0.1:3000/api/v1/conversations`
- 主 Agent 项目详情:`http://127.0.0.1:3000/api/v1/projects/master-agent`
- AI 账号摘要接口:`http://127.0.0.1:3000/api/v1/accounts`
- AI 账号校验接口:`POST http://127.0.0.1:3000/api/v1/accounts/[accountId]/validate`
- 设备 Skill 同步接口:`http://127.0.0.1:3000/api/v1/devices/mac-studio/skills`
- 登录接口:`POST http://127.0.0.1:3000/api/auth/login`
- 登录态接口:`GET http://127.0.0.1:3000/api/auth/session`
@@ -108,6 +109,8 @@ cd /Users/kris/code/boss
- 原生会话页当前的刷新失败策略已改成按当前 tab 独立判错:`会话` 不会再因为 `设备 / OTA / 设置` 的旁路请求失败而整体提示“刷新失败”
- 会话页、设备页、技能页和项目详情页当前都通过 `/api/v1/events` 的 SSE 自动刷新
- 我的页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于` 六个一级入口;`AI 账号` 支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex``Master Codex Node`
- `AI 账号` 页当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth`主 GPT` 需要先在绑定电脑上的 Codex / ChatGPT Plus 会话里登录,再回手机端点“测试连接 / 校验连接”
- `POST /api/v1/accounts/[accountId]/validate` 当前对 `master_codex_node` 不再只看 `nodeId`,还会同时校验绑定设备是否在线;设备离线时返回 `degraded` 和清晰的人类可读提示
- 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本`
- 主 Agent 同步等待窗口当前为 55 秒;若本机 Codex 节点回复更慢,项目页仍会通过 SSE 在任务完成后自动刷新出真实回复
- `GET /api/v1/app-logs` 当前已支持登录态分页查询
@@ -147,6 +150,7 @@ cd /Users/kris/code/boss
- `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理
- 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列
- 当前设备导入决议已经会先落 `device_import_resolution` master task 再写回结果,但决议内容仍是服务端 heuristic 版;下一阶段可再升级成真正通过 `local-agent -> codex exec` 参与理解的主 Agent 决议
- 原生 Android 当前对 `master-agent` 聊天消息已单独放宽读超时到 `65s`;之前默认 `12s` 会把等待 `Master Codex Node / local-agent` 回写的长请求误判成“主 Agent 无响应”
## 2. 服务器状态

View File

@@ -57,6 +57,22 @@ function emptyDraft(): AccountDraft {
};
}
function buildMasterNodeLoginGuide(account: {
nodeLabel?: string;
nodeId?: string;
statusLabel?: string;
}) {
const nodeLabel = account.nodeLabel?.trim() || account.nodeId?.trim() || "绑定设备";
return [
`主 GPT 不在手机里直接登录。`,
`请到绑定设备 ${nodeLabel} 上打开 Codex / ChatGPT Plus 会话完成登录。`,
`登录完成后,回到这里点“测试连接”确认 relay 和主 Agent 已接通。`,
account.statusLabel ? `当前状态:${account.statusLabel}` : null,
]
.filter(Boolean)
.join("\n");
}
function draftFromAccount(account: AiAccountSummary): AccountDraft {
return {
label: account.label,
@@ -142,6 +158,7 @@ export function AiAccountsClient({
const [newDraft, setNewDraft] = useState<AccountDraft>(emptyDraft());
const [busyKey, setBusyKey] = useState<string | null>(null);
const [message, setMessage] = useState("");
const [guideAccountId, setGuideAccountId] = useState<string | null>(null);
const accountDrafts = useMemo(
() =>
@@ -437,6 +454,19 @@ export function AiAccountsClient({
</div>
<div className="mt-4 flex flex-wrap gap-2">
{account.provider === "master_codex_node" ? (
<button
type="button"
onClick={() =>
setGuideAccountId((current) =>
current === account.accountId ? null : account.accountId,
)
}
className="rounded-full border border-[#D9D9D9] px-3 py-2 text-[12px] font-semibold text-[#57606A]"
>
{guideAccountId === account.accountId ? "收起登录指引" : "登录指引"}
</button>
) : null}
<button
type="button"
onClick={() => void validateAccount(account.accountId)}
@@ -472,6 +502,12 @@ export function AiAccountsClient({
</button>
) : null}
</div>
{account.provider === "master_codex_node" && guideAccountId === account.accountId ? (
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[12px] leading-6 text-[#57606A] whitespace-pre-line">
{buildMasterNodeLoginGuide(account)}
</div>
) : null}
</div>
);
})}

View File

@@ -753,13 +753,55 @@ export async function validateAiAccountConnection(accountId: string) {
}
if (account.provider === "master_codex_node") {
const state = await readState();
const nodeId = account.nodeId?.trim() || state.user.boundDeviceId || "";
const boundDevice = state.devices.find((device) => device.id === nodeId);
const boundNodeLabel =
account.nodeLabel?.trim() ||
boundDevice?.name ||
state.user.boundCodexNodeLabel ||
state.user.boundDeviceId ||
"绑定设备";
if (!nodeId) {
await updateAiAccountHealth({
accountId: account.accountId,
status: "needs_login",
lastError: "MASTER_CODEX_NODE_NOT_CONFIGURED",
lastValidatedAt: new Date().toISOString(),
});
return {
ok: false as const,
status: "needs_login" as const,
message: `主 GPT 不在手机里直接登录。请先在绑定设备(例如 ${boundNodeLabel})上的 Codex / ChatGPT Plus 会话里登录,并填写正确的节点 ID再回来校验连接。`,
};
}
if (!boundDevice || boundDevice.status !== "online") {
await updateAiAccountHealth({
accountId: account.accountId,
status: "degraded",
lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE",
lastValidatedAt: new Date().toISOString(),
});
return {
ok: false as const,
status: "degraded" as const,
message: `主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过该节点对话。请先在这台设备上登录 Codex / ChatGPT Plus并确保 local-agent 在线。`,
};
}
await updateAiAccountHealth({
accountId: account.accountId,
status: "ready",
lastError: undefined,
lastValidatedAt: new Date().toISOString(),
lastUsedAt: boundDevice.lastSeenAt || new Date().toISOString(),
});
return {
ok: Boolean(account.nodeId?.trim()) as boolean,
status: account.nodeId?.trim() ? "ready" : "needs_login",
message:
account.nodeId?.trim()
? "Master Codex Node 已配置。主 Agent 会通过 local-agent relay 把任务转交给该节点上的 Codex。"
: "请先填写 Master Codex Node 的节点 ID再让 local-agent 认领主 Agent 任务。",
ok: true as const,
status: "ready" as const,
message: `主 GPT 不在手机里直接登录。当前已通过绑定设备 ${boundNodeLabel} 接好 Master Codex Node主 Agent 会把任务转交给这台设备上的 Codex / ChatGPT Plus 会话。`,
};
}
@@ -811,6 +853,27 @@ export async function replyToMasterAgentUserMessage(params: {
if (runtime.account.provider === "master_codex_node") {
const state = await readState();
const deviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio";
const boundDevice = state.devices.find((device) => device.id === deviceId);
const boundNodeLabel =
runtime.account.nodeLabel?.trim() ||
boundDevice?.name ||
state.user.boundCodexNodeLabel ||
deviceId;
if (!boundDevice || boundDevice.status !== "online") {
await updateAiAccountHealth({
accountId: runtime.account.accountId,
status: "degraded",
lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE",
lastValidatedAt: new Date().toISOString(),
});
await appendMasterAgentSystemReply(
`主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过这台设备对话。请先在该设备上登录 Codex / ChatGPT Plus并确保 local-agent 在线后再重试。`,
`主 Agent · ${runtime.summary.roleLabel}`,
);
return { ok: false as const, reason: "MASTER_NODE_OFFLINE" };
}
const task = await queueMasterAgentTask({
requestMessageId: params.requestMessageId ?? "master-agent-manual",
requestText: params.requestText,
@@ -845,7 +908,7 @@ export async function replyToMasterAgentUserMessage(params: {
await appendMasterAgentSystemReply(
[
`当前主控身份是 ${runtime.summary.roleLabel},任务已经转交到 ${runtime.account.nodeLabel ?? deviceId} 的 Master Codex Node。`,
`当前主控身份是 ${runtime.summary.roleLabel},任务已经转交到 ${boundNodeLabel} 的 Master Codex Node。`,
"如果本机 Codex 节点在线,回复会在稍后自动回写到当前会话。",
].join(""),
`主 Agent · ${runtime.summary.roleLabel}`,

View File

@@ -0,0 +1,79 @@
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 validateAiAccountConnection: (typeof import("../src/lib/boss-master-agent"))["validateAiAccountConnection"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let deviceHeartbeatRoute: (typeof import("../src/app/api/device-heartbeat/route"))["POST"];
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-ai-account-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [masterAgent, data, heartbeatModule] = await Promise.all([
import("../src/lib/boss-master-agent.ts"),
import("../src/lib/boss-data.ts"),
import("../src/app/api/device-heartbeat/route.ts"),
]);
validateAiAccountConnection = masterAgent.validateAiAccountConnection;
readState = data.readState;
deviceHeartbeatRoute = heartbeatModule.POST;
}
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test("validateAiAccountConnection explains master node login happens on the bound device", async () => {
await setup();
const result = await validateAiAccountConnection("master-codex-primary");
assert.equal(result.ok, true);
assert.equal(result.status, "ready");
assert.match(result.message, /不在手机里直接登录/);
assert.match(result.message, /Mac Studio|本机 Codex/);
});
test("validateAiAccountConnection reports degraded when the bound master node device is offline", async () => {
await setup();
const state = await readState();
const device = state.devices.find((item) => item.id === "mac-studio");
assert.ok(device, "expected default mac-studio seed device");
const heartbeatResponse = await deviceHeartbeatRoute(
new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
deviceId: device!.id,
token: device!.token,
name: device!.name,
avatar: device!.avatar,
account: device!.account,
status: "offline",
quota5h: device!.quota5h,
quota7d: device!.quota7d,
projects: device!.projects,
endpoint: device!.endpoint,
}),
}),
);
assert.equal(heartbeatResponse.status, 200);
const result = await validateAiAccountConnection("master-codex-primary");
assert.equal(result.ok, false);
assert.equal(result.status, "degraded");
assert.match(result.message, /当前不在线|不在线/);
});