refactor: keep imported project understanding sync automatic
This commit is contained in:
@@ -134,7 +134,7 @@ Android APK:
|
||||
- 当前设备导入主链已补上真实审核闭环:设备 heartbeat 可上报真实项目候选,服务端会生成 `import draft`;用户提交勾选后会先排队 `device_import_resolution` 主 Agent 任务,前台进入“主 Agent 审核中”并自动刷新,任务完成后才写回正式导入建议,再把选中的线程真正落成聊天窗口
|
||||
- 当前新设备导入前台已经接通:Web `添加设备` 成功后会直接进入“导入项目”步骤;设备页详情里也可再次打开导入草稿。原生 Android 端同样已补 `DeviceImportDraftActivity`,可完成 `勾选 -> 预览决议 -> 应用导入`
|
||||
- 当前设备导入前台文案与状态卡已收口:会明确显示 `等待候选线程 / 等待勾选 / 建议已生成 / 已导入`,并在导入后回显真正落到会话首页的线程名
|
||||
- 当前已导入设备也支持主动同步项目理解:设备详情页新增 `同步项目理解`,会由主 Agent 主动询问这台设备上活跃线程的项目目标、当前进度和技术架构,并把结果回写到项目理解和项目记忆
|
||||
- 当前已导入设备也支持自动同步项目理解:绑定设备 heartbeat 发现活跃线程有新活动、或线程本身刚回写新结果时,都会自动排隐藏的主 Agent 理解任务,把项目目标、当前进度和技术架构回写到项目理解和项目记忆
|
||||
- 当前 `dispatch_execution` 完成回写已补幂等:同一个执行单重复完成,不会再向群聊重复追加线程原始回复和主 Agent 汇总
|
||||
- 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `import draft`,不再绕过勾选/应用阶段直接把旧项目目录导入为聊天窗口
|
||||
- 当前设备导入 `review` 已补 owner/admin 鉴权,并改成真正的异步审核链:`review` 只负责排队 `device_import_resolution` 任务并返回 queued 状态,等 local-agent 完成回写后才把决议写回草稿和会话账本
|
||||
@@ -315,7 +315,6 @@ npm run aab:release
|
||||
- 原生聊天页当前会即时渲染本地发送中消息,并且只有在用户接近底部或本次发送是主动触发时才自动滚到底
|
||||
- 登录成功后的进入首页链路已做稳态处理:会先确认 `/api/auth/session` 可读,再执行 `replace(/conversations)`,并附带一次原生级兜底跳转,避免真机 WebView 偶发停留在“正在进入会话首页”
|
||||
- `/api/v1/events` 已作为 SSE 出口使用,会话页、设备页、技能页和项目详情页会按事件自动刷新,不再只靠手动刷新
|
||||
- `/api/v1/devices/[deviceId]/project-understanding-sync` 已可用:设备详情页可手动触发当前设备活跃线程的项目理解同步,适用于已经导入过线程的生产设备
|
||||
- 我的页新增 `技能` 入口,`/me/skills` 会按设备分组展示 Skill,并支持一键复制调用语句
|
||||
- 我的页新增 `主 Agent 提示词 / 记忆` 入口,`/me/master-agent` 会展示管理员全局主提示词、用户主提示词、当前对话附加提示词、组合预览,以及当前用户的通用记忆和跨项目项目记忆
|
||||
- 我的页新增 `AI 账号` 入口,`/me/ai-accounts` 会展示主 GPT / 备用 GPT / API 容灾,并明确主链路优先走已登录 `ChatGPT Plus / Codex` 的 `Master Codex Node`
|
||||
|
||||
@@ -399,10 +399,6 @@ public class BossApiClient {
|
||||
return requestWithRestore("POST", "/api/v1/devices/" + encode(deviceId) + "/import-draft/apply", new JSONObject());
|
||||
}
|
||||
|
||||
public ApiResponse syncDeviceProjectUnderstanding(String deviceId) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/devices/" + encode(deviceId) + "/project-understanding-sync", new JSONObject());
|
||||
}
|
||||
|
||||
public ApiResponse getAccounts() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/accounts", null);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
));
|
||||
}
|
||||
appendContent(BossUi.buildMenuRow(this, "导入项目", "勾选这台设备上要暴露到会话首页的项目和线程", null, v -> openImportDraft()));
|
||||
appendContent(BossUi.buildMenuRow(this, "同步项目理解", "让主 Agent 主动询问这台设备上的活跃项目目标、进度和架构", null, v -> syncProjectUnderstanding()));
|
||||
appendContent(BossUi.buildMenuRow(this, "查看技能", "查看当前设备同步的 Skill 清单", null, v -> openSkills()));
|
||||
setRefreshing(false);
|
||||
}
|
||||
@@ -94,33 +93,6 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void syncProjectUnderstanding() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.syncDeviceProjectUnderstanding(deviceId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
JSONObject payload = response.json;
|
||||
JSONArray queuedTasks = payload.optJSONArray("queuedTasks");
|
||||
int queuedCount = queuedTasks == null ? 0 : queuedTasks.length();
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
if (queuedCount <= 0) {
|
||||
showMessage("当前设备没有可同步的活跃线程。");
|
||||
} else {
|
||||
showMessage("主 Agent 已开始同步 " + queuedCount + " 个项目理解。");
|
||||
}
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("同步失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void openEditDialog() {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@@ -9,8 +8,6 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
@@ -18,14 +15,13 @@ import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowToast;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class DeviceDetailActivityTest {
|
||||
@Test
|
||||
public void renderDeviceShowsSyncProjectUnderstandingEntry() {
|
||||
public void renderDeviceDoesNotShowManualProjectUnderstandingEntry() {
|
||||
TestDeviceDetailActivity activity = Robolectric
|
||||
.buildActivity(
|
||||
TestDeviceDetailActivity.class,
|
||||
@@ -37,30 +33,8 @@ public class DeviceDetailActivityTest {
|
||||
.get();
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "同步项目理解"));
|
||||
assertTrue(viewTreeContainsText(content, "让主 Agent 主动询问这台设备上的活跃项目目标、进度和架构"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tappingSyncProjectUnderstandingCallsApiAndShowsQueuedCount() {
|
||||
TestDeviceDetailActivity activity = Robolectric
|
||||
.buildActivity(
|
||||
TestDeviceDetailActivity.class,
|
||||
new Intent()
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
|
||||
)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
View syncLabel = findViewWithText(activity.findViewById(R.id.screen_content), "同步项目理解");
|
||||
syncLabel.getParent().getParent();
|
||||
View clickable = findClickableAncestor(syncLabel);
|
||||
clickable.performClick();
|
||||
org.robolectric.Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(1, activity.fakeClient.syncCalls);
|
||||
assertEquals("主 Agent 已开始同步 2 个项目理解。", ShadowToast.getTextOfLatestToast());
|
||||
assertFalse(viewTreeContainsText(content, "同步项目理解"));
|
||||
assertFalse(viewTreeContainsText(content, "让主 Agent 主动询问这台设备上的活跃项目目标、进度和架构"));
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
@@ -82,47 +56,10 @@ public class DeviceDetailActivityTest {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static View findViewWithText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
if (text != null && text.toString().contains(expectedText)) {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return null;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
View match = findViewWithText(group.getChildAt(index), expectedText);
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static View findClickableAncestor(View view) {
|
||||
View current = view;
|
||||
while (current != null && !current.isClickable()) {
|
||||
if (!(current.getParent() instanceof View)) {
|
||||
break;
|
||||
}
|
||||
current = (View) current.getParent();
|
||||
}
|
||||
return current == null ? view : current;
|
||||
}
|
||||
|
||||
public static class TestDeviceDetailActivity extends DeviceDetailActivity {
|
||||
FakeBossApiClient fakeClient;
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (fakeClient == null) {
|
||||
fakeClient = new FakeBossApiClient(this);
|
||||
}
|
||||
this.apiClient = fakeClient;
|
||||
this.apiClient = new BossApiClient(getSharedPreferences("test-boss-api", Context.MODE_PRIVATE), "https://boss.hyzq.net");
|
||||
try {
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
this,
|
||||
@@ -150,29 +87,4 @@ public class DeviceDetailActivityTest {
|
||||
.put("note", "测试设备")));
|
||||
}
|
||||
}
|
||||
|
||||
private static class FakeBossApiClient extends BossApiClient {
|
||||
int syncCalls = 0;
|
||||
|
||||
FakeBossApiClient(DeviceDetailActivity activity) {
|
||||
super(activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE), "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse syncDeviceProjectUnderstanding(String deviceId) {
|
||||
syncCalls += 1;
|
||||
try {
|
||||
return new ApiResponse(
|
||||
200,
|
||||
new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("queuedTasks", new JSONArray()
|
||||
.put(new JSONObject().put("projectId", "project-1"))
|
||||
.put(new JSONObject().put("projectId", "project-2")))
|
||||
);
|
||||
} catch (Exception error) {
|
||||
throw new RuntimeException(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,18 +763,6 @@
|
||||
- 已绑定的生产设备如果在 heartbeat 中携带真实 `projectCandidates[]`,服务端会自动完成 `select + review + apply`
|
||||
- 新设备仍保持人工勾选导入流程,不会被自动跳过
|
||||
|
||||
#### `POST /api/v1/devices/[deviceId]/project-understanding-sync`
|
||||
|
||||
- 用途:对已经导入过线程的设备,手动触发当前活跃项目理解同步
|
||||
- 当前行为:
|
||||
- 读取该设备上已导入的真实线程会话
|
||||
- 选出最近活跃的线程项目
|
||||
- 为这些项目排隐藏的 `conversation_reply` 主 Agent 任务
|
||||
- 强制刷新项目理解,即使上次同步时间较近也会重新询问
|
||||
- 返回本次排队的任务列表和当前活跃项目摘要
|
||||
- 当前保护:
|
||||
- 仅 `highest_admin` 或设备所属账号可写
|
||||
|
||||
#### `GET /api/v1/devices/[deviceId]/skills`
|
||||
|
||||
- 用途:读取指定设备已经同步上来的 Skill 列表
|
||||
|
||||
@@ -129,7 +129,7 @@ cd /Users/kris/code/boss
|
||||
- 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口
|
||||
- Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链
|
||||
- 当前设备导入前台的状态表达已经统一为:`等待候选线程 / 等待勾选 / 建议生成中 / 建议已生成 / 已导入`,并会回显最终导入的线程名
|
||||
- 当前已导入设备也支持主动同步项目理解:设备详情页新增 `同步项目理解`,会直接为这台设备上已导入的活跃线程排隐藏的 `conversation_reply` 主 Agent 任务,回写最新的项目目标、当前进度、技术架构和下一步建议
|
||||
- 当前已导入设备也支持自动同步项目理解:绑定设备 heartbeat 发现活跃线程有新活动、或线程刚回写了新的执行结果时,系统会直接为这台设备上已导入的线程排隐藏的 `conversation_reply` 主 Agent 任务,回写最新的项目目标、当前进度、技术架构和下一步建议
|
||||
- 当前群资料页已补上“修复群成员”入口:当群里存在失效线程引用、`master-agent` 这类不可下发成员,或真实线程成员少于 2 个时,前台会明确提示并允许重新选择真实线程成员
|
||||
- 当前原生聊天页也已前移“修复群成员”入口:脏群会在消息流上方直接显示 `去修复` 按钮,并跳转到群资料页完成成员替换
|
||||
- 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `deviceImportDraft`,不再绕过勾选/审核阶段直接自动导入聊天窗口
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authorizeDeviceSessionRequest } from "@/lib/boss-device-auth";
|
||||
import { syncDeviceProjectUnderstanding } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ deviceId: string }> },
|
||||
) {
|
||||
const { deviceId } = await context.params;
|
||||
const auth = await authorizeDeviceSessionRequest(request, deviceId);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: auth.status === 404 ? "DEVICE_NOT_FOUND" : "UNAUTHORIZED" },
|
||||
{ status: auth.status },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncDeviceProjectUnderstanding({
|
||||
deviceId,
|
||||
requestedByAccount: auth.session.account,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -677,7 +677,7 @@ export interface MasterAgentTask {
|
||||
deviceImportCandidateId?: string;
|
||||
deviceImportCandidateFolderName?: string;
|
||||
projectUnderstandingTargetProjectId?: string;
|
||||
projectUnderstandingReason?: "heartbeat_activity" | "thread_reply" | "manual_device_sync";
|
||||
projectUnderstandingReason?: "heartbeat_activity" | "thread_reply";
|
||||
status: MasterAgentTaskStatus;
|
||||
requestedAt: string;
|
||||
claimedAt?: string;
|
||||
@@ -2995,9 +2995,7 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
||||
deviceImportCandidateFolderName: task.deviceImportCandidateFolderName,
|
||||
projectUnderstandingTargetProjectId: task.projectUnderstandingTargetProjectId,
|
||||
projectUnderstandingReason:
|
||||
task.projectUnderstandingReason === "heartbeat_activity" ||
|
||||
task.projectUnderstandingReason === "thread_reply" ||
|
||||
task.projectUnderstandingReason === "manual_device_sync"
|
||||
task.projectUnderstandingReason === "heartbeat_activity" || task.projectUnderstandingReason === "thread_reply"
|
||||
? task.projectUnderstandingReason
|
||||
: undefined,
|
||||
status: task.status ?? "queued",
|
||||
@@ -5207,7 +5205,7 @@ export async function queueMasterAgentTask(payload: {
|
||||
deviceImportCandidateId?: string;
|
||||
deviceImportCandidateFolderName?: string;
|
||||
projectUnderstandingTargetProjectId?: string;
|
||||
projectUnderstandingReason?: "heartbeat_activity" | "thread_reply" | "manual_device_sync";
|
||||
projectUnderstandingReason?: "heartbeat_activity" | "thread_reply";
|
||||
}) {
|
||||
const task = await mutateState((state) => {
|
||||
const task: MasterAgentTask = {
|
||||
@@ -7356,12 +7354,7 @@ function applyProjectUnderstandingSnapshotInState(
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function shouldQueueProjectUnderstandingSync(
|
||||
project: Project,
|
||||
observedActivityAt: string,
|
||||
state: BossState,
|
||||
options?: { force?: boolean },
|
||||
) {
|
||||
function shouldQueueProjectUnderstandingSync(project: Project, observedActivityAt: string, state: BossState) {
|
||||
if (!isDispatchableThreadProject(project)) {
|
||||
return false;
|
||||
}
|
||||
@@ -7369,16 +7362,14 @@ function shouldQueueProjectUnderstandingSync(
|
||||
if (!Number.isFinite(observedTs)) {
|
||||
return false;
|
||||
}
|
||||
if (!options?.force) {
|
||||
const latestWatermark = Date.parse(
|
||||
project.threadMeta.lastProjectUnderstandingRequestedAt ??
|
||||
project.threadMeta.lastProjectUnderstandingSyncedAt ??
|
||||
project.projectUnderstanding?.updatedAt ??
|
||||
"1970-01-01T00:00:00.000Z",
|
||||
);
|
||||
if (Number.isFinite(latestWatermark) && observedTs <= latestWatermark) {
|
||||
return false;
|
||||
}
|
||||
const latestWatermark = Date.parse(
|
||||
project.threadMeta.lastProjectUnderstandingRequestedAt ??
|
||||
project.threadMeta.lastProjectUnderstandingSyncedAt ??
|
||||
project.projectUnderstanding?.updatedAt ??
|
||||
"1970-01-01T00:00:00.000Z",
|
||||
);
|
||||
if (Number.isFinite(latestWatermark) && observedTs <= latestWatermark) {
|
||||
return false;
|
||||
}
|
||||
return !state.masterAgentTasks.some(
|
||||
(task) =>
|
||||
@@ -7389,22 +7380,13 @@ function shouldQueueProjectUnderstandingSync(
|
||||
);
|
||||
}
|
||||
|
||||
function buildProjectUnderstandingSyncPrompt(
|
||||
project: Project,
|
||||
reason: "heartbeat_activity" | "thread_reply" | "manual_device_sync",
|
||||
) {
|
||||
function buildProjectUnderstandingSyncPrompt(project: Project, reason: "heartbeat_activity" | "thread_reply") {
|
||||
return [
|
||||
"你正在向主 Agent 同步当前项目状态。",
|
||||
`项目名称:${project.name}`,
|
||||
`线程名称:${project.threadMeta.threadDisplayName}`,
|
||||
`文件夹:${project.threadMeta.folderName}`,
|
||||
`同步原因:${
|
||||
reason === "heartbeat_activity"
|
||||
? "检测到线程有新活动"
|
||||
: reason === "thread_reply"
|
||||
? "线程刚刚产生了新的执行结果"
|
||||
: "用户主动要求同步当前设备上的活跃项目理解"
|
||||
}`,
|
||||
`同步原因:${reason === "heartbeat_activity" ? "检测到线程有新活动" : "线程刚刚产生了新的执行结果"}`,
|
||||
"",
|
||||
"只输出 JSON,不要输出解释性文字或 Markdown。",
|
||||
"JSON 结构固定为:",
|
||||
@@ -7420,17 +7402,14 @@ function buildProjectUnderstandingSyncPrompt(
|
||||
async function queueProjectUnderstandingSyncTask(input: {
|
||||
projectId: string;
|
||||
observedActivityAt: string;
|
||||
reason: "heartbeat_activity" | "thread_reply" | "manual_device_sync";
|
||||
force?: boolean;
|
||||
requestedByAccount?: string;
|
||||
reason: "heartbeat_activity" | "thread_reply";
|
||||
}) {
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === input.projectId);
|
||||
if (!project || !shouldQueueProjectUnderstandingSync(project, input.observedActivityAt, state, { force: input.force })) {
|
||||
if (!project || !shouldQueueProjectUnderstandingSync(project, input.observedActivityAt, state)) {
|
||||
return null;
|
||||
}
|
||||
const requestedByAccount =
|
||||
input.requestedByAccount?.trim() || state.user.account || project.deviceIds[0] || "17600003315";
|
||||
const requestedByAccount = state.user.account || project.deviceIds[0] || "17600003315";
|
||||
const task = await queueMasterAgentTask({
|
||||
projectId: "master-agent",
|
||||
taskType: "conversation_reply",
|
||||
@@ -7463,82 +7442,6 @@ async function queueProjectUnderstandingSyncTask(input: {
|
||||
return task;
|
||||
}
|
||||
|
||||
export async function syncDeviceProjectUnderstanding(input: {
|
||||
deviceId: string;
|
||||
requestedByAccount: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
const state = await readState();
|
||||
const device = state.devices.find((item) => item.id === input.deviceId);
|
||||
if (!device) {
|
||||
throw new Error("DEVICE_NOT_FOUND");
|
||||
}
|
||||
|
||||
const activeProjects = state.projects
|
||||
.filter(
|
||||
(project) =>
|
||||
!project.isGroup &&
|
||||
project.deviceIds.includes(input.deviceId) &&
|
||||
isDispatchableThreadProject(project) &&
|
||||
Boolean(project.threadMeta.codexThreadRef?.trim()),
|
||||
)
|
||||
.sort((left, right) =>
|
||||
String(
|
||||
right.threadMeta.lastObservedCodexActivityAt ??
|
||||
right.projectUnderstanding?.updatedAt ??
|
||||
right.lastMessageAt,
|
||||
).localeCompare(
|
||||
String(
|
||||
left.threadMeta.lastObservedCodexActivityAt ??
|
||||
left.projectUnderstanding?.updatedAt ??
|
||||
left.lastMessageAt,
|
||||
),
|
||||
),
|
||||
)
|
||||
.slice(0, Math.max(1, input.limit ?? 3));
|
||||
|
||||
const queuedTasks = [];
|
||||
for (const project of activeProjects) {
|
||||
const observedActivityAt =
|
||||
project.threadMeta.lastObservedCodexActivityAt ??
|
||||
project.projectUnderstanding?.updatedAt ??
|
||||
project.lastMessageAt ??
|
||||
nowIso();
|
||||
const task = await queueProjectUnderstandingSyncTask({
|
||||
projectId: project.id,
|
||||
observedActivityAt,
|
||||
reason: "manual_device_sync",
|
||||
force: true,
|
||||
requestedByAccount: input.requestedByAccount,
|
||||
});
|
||||
if (task) {
|
||||
queuedTasks.push({
|
||||
taskId: task.taskId,
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
threadDisplayName: project.threadMeta.threadDisplayName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
queuedTasks,
|
||||
activeProjects: activeProjects.map((project) => ({
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
threadDisplayName: project.threadMeta.threadDisplayName,
|
||||
lastObservedCodexActivityAt:
|
||||
project.threadMeta.lastObservedCodexActivityAt ??
|
||||
project.projectUnderstanding?.updatedAt ??
|
||||
project.lastMessageAt,
|
||||
currentProgress: project.projectUnderstanding?.currentProgress ?? "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function previewDeviceImportResolution(input: { deviceId: string }) {
|
||||
const state = await readState();
|
||||
const draft = state.deviceImportDrafts.find((item) => item.deviceId === input.deviceId);
|
||||
|
||||
@@ -13,7 +13,6 @@ let selectImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId]
|
||||
let reviewImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/import-draft/review/route"))["POST"];
|
||||
let completeMasterTaskRoute: (typeof import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route"))["POST"];
|
||||
let applyImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/import-draft/apply/route"))["POST"];
|
||||
let syncDeviceProjectUnderstandingRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/project-understanding-sync/route"))["POST"];
|
||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let AUTH_SESSION_COOKIE = "";
|
||||
@@ -25,7 +24,7 @@ async function setup() {
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [enrollmentModule, heartbeatModule, importDraftModule, selectModule, reviewModule, completeModule, applyModule, syncModule, data, auth] =
|
||||
const [enrollmentModule, heartbeatModule, importDraftModule, selectModule, reviewModule, completeModule, applyModule, data, auth] =
|
||||
await Promise.all([
|
||||
import("../src/app/api/v1/devices/enrollments/route.ts"),
|
||||
import("../src/app/api/device-heartbeat/route.ts"),
|
||||
@@ -34,7 +33,6 @@ async function setup() {
|
||||
import("../src/app/api/v1/devices/[deviceId]/import-draft/review/route.ts"),
|
||||
import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts"),
|
||||
import("../src/app/api/v1/devices/[deviceId]/import-draft/apply/route.ts"),
|
||||
import("../src/app/api/v1/devices/[deviceId]/project-understanding-sync/route.ts"),
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
]);
|
||||
@@ -46,7 +44,6 @@ async function setup() {
|
||||
reviewImportDraftRoute = reviewModule.POST;
|
||||
completeMasterTaskRoute = completeModule.POST;
|
||||
applyImportDraftRoute = applyModule.POST;
|
||||
syncDeviceProjectUnderstandingRoute = syncModule.POST;
|
||||
createAuthSession = data.createAuthSession;
|
||||
readState = data.readState;
|
||||
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
||||
@@ -614,220 +611,6 @@ test("imported thread projects queue hidden understanding sync tasks on newer ac
|
||||
);
|
||||
});
|
||||
|
||||
test("existing imported devices can manually trigger project understanding sync from the device route", async () => {
|
||||
await setup();
|
||||
|
||||
const enrollmentResponse = await createEnrollmentRoute(
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", {
|
||||
name: "Mac Studio Existing",
|
||||
avatar: "E",
|
||||
account: "17600003315",
|
||||
endpoint: "mac://existing.local",
|
||||
note: "manual project sync",
|
||||
}),
|
||||
);
|
||||
assert.equal(enrollmentResponse.status, 200);
|
||||
const enrollmentPayload = (await enrollmentResponse.json()) as {
|
||||
enrollment: { pairingCode: string };
|
||||
device: { id: string };
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await deviceHeartbeatRoute(
|
||||
new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
deviceId: enrollmentPayload.device.id,
|
||||
pairingCode: enrollmentPayload.enrollment.pairingCode,
|
||||
name: "Mac Studio Existing",
|
||||
avatar: "E",
|
||||
account: "17600003315",
|
||||
status: "online",
|
||||
quota5h: 69,
|
||||
quota7d: 86,
|
||||
projects: [],
|
||||
endpoint: "mac://existing.local",
|
||||
projectCandidates: [
|
||||
{
|
||||
folderName: "视觉控制台",
|
||||
folderRef: "vision-console",
|
||||
threadId: "thread-vision-console",
|
||||
threadDisplayName: "视觉控制台主线程",
|
||||
codexFolderRef: "vision-console",
|
||||
codexThreadRef: "thread-vision-console",
|
||||
lastActiveAt: "2026-03-30T12:00:00+08:00",
|
||||
suggestedImport: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
|
||||
const draftResponse = await getImportDraftRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft`,
|
||||
"GET",
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
);
|
||||
const draftPayload = (await draftResponse.json()) as {
|
||||
draft: { candidates: Array<{ candidateId: string }> };
|
||||
};
|
||||
const selectedCandidateIds = draftPayload.draft.candidates.map((candidate) => candidate.candidateId);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await selectImportDraftRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/select`,
|
||||
"POST",
|
||||
{ selectedCandidateIds },
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await reviewImportDraftRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/review`,
|
||||
"POST",
|
||||
{},
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
|
||||
let currentState = await readState();
|
||||
const reviewTask = currentState.masterAgentTasks.find(
|
||||
(task) =>
|
||||
task.taskType === "device_import_resolution" &&
|
||||
task.deviceImportDraftId &&
|
||||
task.status === "queued",
|
||||
);
|
||||
const understandingTask = currentState.masterAgentTasks.find(
|
||||
(task) =>
|
||||
task.taskType === "conversation_reply" &&
|
||||
task.deviceImportDraftId &&
|
||||
task.deviceImportCandidateId &&
|
||||
task.status === "queued",
|
||||
);
|
||||
assert.ok(reviewTask);
|
||||
assert.ok(understandingTask);
|
||||
|
||||
const reviewCompleteResponse = await completeMasterTaskRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/master-agent/tasks/${reviewTask?.taskId}/complete`,
|
||||
"POST",
|
||||
{
|
||||
deviceId: reviewTask?.deviceId,
|
||||
status: "completed",
|
||||
replyBody: JSON.stringify(
|
||||
{
|
||||
summary: "Mac Studio Existing 导入建议:将视觉控制台主线程导入为独立会话。",
|
||||
items: selectedCandidateIds.map((candidateId) => ({
|
||||
candidateId,
|
||||
action: "create_thread_conversation",
|
||||
reason: "需要保留独立上下文,建议新建会话。",
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
),
|
||||
{ params: Promise.resolve({ taskId: reviewTask?.taskId ?? "" }) },
|
||||
);
|
||||
const reviewCompleteRaw = await reviewCompleteResponse.text();
|
||||
if (reviewCompleteResponse.status !== 200) {
|
||||
assert.fail(reviewCompleteRaw);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await completeMasterTaskRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/master-agent/tasks/${understandingTask?.taskId}/complete`,
|
||||
"POST",
|
||||
{
|
||||
deviceId: enrollmentPayload.device.id,
|
||||
status: "completed",
|
||||
replyBody: JSON.stringify(
|
||||
{
|
||||
projectGoal: "完成视觉控制台与设备实时状态打通。",
|
||||
currentProgress: "已完成导入,当前项目处于持续联调阶段。",
|
||||
technicalArchitecture: "Android 前端通过 Boss Web 与 local-agent 对接活跃 Codex 线程。",
|
||||
currentBlockers: "视觉状态回流还不够实时。",
|
||||
recommendedNextStep: "继续压缩状态同步延迟。",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
),
|
||||
{ params: Promise.resolve({ taskId: understandingTask?.taskId ?? "" }) },
|
||||
)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await applyImportDraftRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/apply`,
|
||||
"POST",
|
||||
{},
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
|
||||
const syncResponse = await syncDeviceProjectUnderstandingRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/project-understanding-sync`,
|
||||
"POST",
|
||||
{},
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
);
|
||||
const syncRaw = await syncResponse.text();
|
||||
if (syncResponse.status !== 200) {
|
||||
assert.fail(syncRaw);
|
||||
}
|
||||
const syncPayload = JSON.parse(syncRaw) as {
|
||||
queuedTasks?: Array<{ projectId: string; threadDisplayName: string }>;
|
||||
};
|
||||
assert.equal(syncPayload.queuedTasks?.length, 1);
|
||||
assert.equal(syncPayload.queuedTasks?.[0]?.threadDisplayName, "视觉控制台主线程");
|
||||
|
||||
currentState = await readState();
|
||||
const importedProject = currentState.projects.find(
|
||||
(project) => project.threadMeta.codexThreadRef === "thread-vision-console",
|
||||
);
|
||||
const manualSyncTask = currentState.masterAgentTasks.find(
|
||||
(task) =>
|
||||
task.taskType === "conversation_reply" &&
|
||||
task.projectId === "master-agent" &&
|
||||
task.projectUnderstandingTargetProjectId === importedProject?.id &&
|
||||
task.projectUnderstandingReason === "manual_device_sync" &&
|
||||
task.status === "queued",
|
||||
);
|
||||
assert.ok(manualSyncTask, "expected manual device sync route to queue a hidden understanding task");
|
||||
});
|
||||
|
||||
test("heartbeat candidates no longer auto-create chat windows from legacy projects when import draft is present", async () => {
|
||||
await setup();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user