Refresh goals and versions on precise goal updates

This commit is contained in:
kris
2026-04-07 16:41:09 +08:00
parent 9268f64506
commit b5d6495017
9 changed files with 527 additions and 6 deletions

View File

@@ -12,12 +12,19 @@ import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.LinkedHashMap;
import java.util.Map;
public class ProjectGoalsActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
private static final String GOAL_REFRESH_NOTE = "project_goals.updated";
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String projectId;
private String projectName;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
@@ -26,11 +33,35 @@ public class ProjectGoalsActivity extends BossScreenActivity {
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
configureScreen("项目目标", projectName == null ? "原生目标清单" : projectName);
setHeaderAction("编辑目标", v -> openGoalEditor(null, ""));
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void onDestroy() {
stopRealtimeUpdates();
super.onDestroy();
}
@Override
protected void reload() {
if (projectId == null || projectId.isEmpty()) {
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
setRefreshing(false);
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
@@ -46,6 +77,70 @@ public class ProjectGoalsActivity extends BossScreenActivity {
});
}
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) {
return;
}
if (!shouldReloadForRealtimeEvent(event)) {
return;
}
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
if (eventFingerprint.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return;
}
runOnUiThread(this::reload);
}
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
if (!"conversation.updated".equals(event.eventName)) {
return false;
}
String payloadProjectId = event.payload.optString("projectId", "").trim();
if (payloadProjectId.isEmpty()) {
return false;
}
String payloadNote = event.payload.optString("note", "").trim();
return payloadProjectId.equals(projectId) && GOAL_REFRESH_NOTE.equals(payloadNote);
}
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
pruneRecentRealtimeEvents(now);
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
return true;
}
recentRealtimeEventTimestamps.put(eventFingerprint, now);
return false;
}
private void pruneRecentRealtimeEvents(long now) {
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Long> entry = iterator.next();
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
iterator.remove();
}
}
}
private void renderGoals(@Nullable JSONObject project) {
replaceContent();
if (project == null) {

View File

@@ -7,11 +7,18 @@ import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.LinkedHashMap;
import java.util.Map;
public class ProjectVersionsActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
private static final String GOAL_REFRESH_NOTE = "project_goals.updated";
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String projectId;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
@@ -19,11 +26,35 @@ public class ProjectVersionsActivity extends BossScreenActivity {
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
configureScreen("版本迭代记录", getIntent().getStringExtra(EXTRA_PROJECT_NAME));
setHeaderAction("只读", v -> showMessage("版本记录只读"));
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void onDestroy() {
stopRealtimeUpdates();
super.onDestroy();
}
@Override
protected void reload() {
if (projectId == null || projectId.isEmpty()) {
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
setRefreshing(false);
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
@@ -39,6 +70,70 @@ public class ProjectVersionsActivity extends BossScreenActivity {
});
}
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) {
return;
}
if (!shouldReloadForRealtimeEvent(event)) {
return;
}
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
if (eventFingerprint.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return;
}
runOnUiThread(this::reload);
}
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
if (!"conversation.updated".equals(event.eventName)) {
return false;
}
String payloadProjectId = event.payload.optString("projectId", "").trim();
if (payloadProjectId.isEmpty()) {
return false;
}
String payloadNote = event.payload.optString("note", "").trim();
return payloadProjectId.equals(projectId) && GOAL_REFRESH_NOTE.equals(payloadNote);
}
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
pruneRecentRealtimeEvents(now);
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
return true;
}
recentRealtimeEventTimestamps.put(eventFingerprint, now);
return false;
}
private void pruneRecentRealtimeEvents(long now) {
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Long> entry = iterator.next();
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
iterator.remove();
}
}
}
private void renderVersions(@Nullable JSONObject project) {
replaceContent(BossUi.buildCard(
this,

View File

@@ -0,0 +1,106 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import android.content.Intent;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import java.lang.reflect.Method;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ProjectGoalsActivityTest {
@Test
public void matchingConversationUpdatedEventTriggersReload() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入");
TestProjectGoalsActivity activity = Robolectric
.buildActivity(TestProjectGoalsActivity.class, intent)
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
Method handleRealtimeEvent = findHandleRealtimeEvent();
assertNotNull(handleRealtimeEvent);
handleRealtimeEvent.invoke(
activity,
new BossRealtimeEvent(
"conversation.updated",
new JSONObject()
.put("projectId", "project-1")
.put("note", "project_goals.updated")
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.reloadCount);
}
@Test
public void sameProjectNonGoalEventDoesNotTriggerReload() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入");
TestProjectGoalsActivity activity = Robolectric
.buildActivity(TestProjectGoalsActivity.class, intent)
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
Method handleRealtimeEvent = findHandleRealtimeEvent();
assertNotNull(handleRealtimeEvent);
handleRealtimeEvent.invoke(
activity,
new BossRealtimeEvent(
"conversation.updated",
new JSONObject()
.put("projectId", "project-1")
.put("note", "dispatch_execution.updated")
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.reloadCount);
}
private static Method findHandleRealtimeEvent() {
for (Method method : ProjectGoalsActivity.class.getDeclaredMethods()) {
if ("handleRealtimeEvent".equals(method.getName())
&& method.getParameterTypes().length == 1
&& method.getParameterTypes()[0] == BossRealtimeEvent.class) {
method.setAccessible(true);
return method;
}
}
return null;
}
public static class TestProjectGoalsActivity extends ProjectGoalsActivity {
private boolean reloadEnabled;
private int reloadCount;
@Override
protected void reload() {
if (!reloadEnabled) {
return;
}
reloadCount += 1;
setRefreshing(false);
}
}
}

View File

@@ -0,0 +1,106 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import android.content.Intent;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import java.lang.reflect.Method;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ProjectVersionsActivityTest {
@Test
public void matchingGoalRefreshMarkerTriggersReload() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入");
TestProjectVersionsActivity activity = Robolectric
.buildActivity(TestProjectVersionsActivity.class, intent)
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
Method handleRealtimeEvent = findHandleRealtimeEvent();
assertNotNull(handleRealtimeEvent);
handleRealtimeEvent.invoke(
activity,
new BossRealtimeEvent(
"conversation.updated",
new JSONObject()
.put("projectId", "project-1")
.put("note", "project_goals.updated")
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.reloadCount);
}
@Test
public void sameProjectNonGoalEventDoesNotTriggerReload() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入");
TestProjectVersionsActivity activity = Robolectric
.buildActivity(TestProjectVersionsActivity.class, intent)
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
Method handleRealtimeEvent = findHandleRealtimeEvent();
assertNotNull(handleRealtimeEvent);
handleRealtimeEvent.invoke(
activity,
new BossRealtimeEvent(
"conversation.updated",
new JSONObject()
.put("projectId", "project-1")
.put("note", "dispatch_execution.updated")
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.reloadCount);
}
private static Method findHandleRealtimeEvent() {
for (Method method : ProjectVersionsActivity.class.getDeclaredMethods()) {
if ("handleRealtimeEvent".equals(method.getName())
&& method.getParameterTypes().length == 1
&& method.getParameterTypes()[0] == BossRealtimeEvent.class) {
method.setAccessible(true);
return method;
}
}
return null;
}
public static class TestProjectVersionsActivity extends ProjectVersionsActivity {
private boolean reloadEnabled;
private int reloadCount;
@Override
protected void reload() {
if (!reloadEnabled) {
return;
}
reloadCount += 1;
setRefreshing(false);
}
}
}

View File

@@ -1,9 +1,9 @@
{
"fileName": "boss-android-v2.5.11-release.apk",
"urlPath": "/api/v1/user/ota/package",
"sizeBytes": 3355587,
"updatedAt": "2026-04-07T08:13:31Z",
"sha256": "0a94ce924344c02914f78414ad6bb5276126124e8eae123508a52e2c33aa86d7",
"sizeBytes": 3356442,
"updatedAt": "2026-04-07T08:39:26Z",
"sha256": "292abbd4f6e7953b3e8d8f195ee1c463d16e973a2d81e7a9760a71951167d47a",
"versionName": "2.5.11",
"versionCode": 24,
"buildFlavor": "release"

View File

@@ -5040,7 +5040,7 @@ export async function getPreferredDeviceIdForAccount(
}
export async function toggleGoal(projectId: string, goalId: string) {
return mutateState((state) => {
const goal = await mutateState((state) => {
const project = state.projects.find((item) => item.id === projectId);
if (!project) throw new Error("PROJECT_NOT_FOUND");
@@ -5062,10 +5062,12 @@ export async function toggleGoal(projectId: string, goalId: string) {
project.lastMessageAt = nowIso();
return goal;
});
publishBossEvent("conversation.updated", { projectId, note: "project_goals.updated" });
return goal;
}
export async function updateGoalText(projectId: string, goalId: string, text: string) {
return mutateState((state) => {
const goal = await mutateState((state) => {
const project = state.projects.find((item) => item.id === projectId);
if (!project) throw new Error("PROJECT_NOT_FOUND");
@@ -5078,10 +5080,12 @@ export async function updateGoalText(projectId: string, goalId: string, text: st
project.lastMessageAt = nowIso();
return goal;
});
publishBossEvent("conversation.updated", { projectId, note: "project_goals.updated" });
return goal;
}
export async function createGoal(projectId: string, text: string) {
return mutateState((state) => {
const goal = await mutateState((state) => {
const project = state.projects.find((item) => item.id === projectId);
if (!project) throw new Error("PROJECT_NOT_FOUND");
if (!text.trim()) throw new Error("GOAL_TEXT_REQUIRED");
@@ -5097,6 +5101,8 @@ export async function createGoal(projectId: string, text: string) {
project.lastMessageAt = nowIso();
return goal;
});
publishBossEvent("conversation.updated", { projectId, note: "project_goals.updated" });
return goal;
}
export async function issueVerificationCode(

View File

@@ -0,0 +1,113 @@
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";
let runtimeRoot = "";
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let toggleGoal: (typeof import("../src/lib/boss-data"))["toggleGoal"];
let updateGoalText: (typeof import("../src/lib/boss-data"))["updateGoalText"];
let createGoal: (typeof import("../src/lib/boss-data"))["createGoal"];
let subscribeBossEvents: (typeof import("../src/lib/boss-events"))["subscribeBossEvents"];
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-goal-events-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [data, events] = await Promise.all([
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-events.ts"),
]);
readState = data.readState;
writeState = data.writeState;
toggleGoal = data.toggleGoal;
updateGoalText = data.updateGoalText;
createGoal = data.createGoal;
subscribeBossEvents = events.subscribeBossEvents;
}
async function resetGoalState() {
const state = await readState();
const existingProject = state.projects.find((project) => project.id !== "master-agent") ?? state.projects[0];
const project = structuredClone(existingProject);
project.id = "project-goal-events";
project.name = "项目目标事件测试";
project.goals = [
{
id: "goal-1",
text: "完成接入验证",
state: "pending",
note: "等待执行",
},
];
project.versions = [];
project.messages = [];
project.lastMessageAt = "2026-04-07T10:00:00.000Z";
state.projects = state.projects.filter((item) => item.id !== "project-goal-events");
state.projects.unshift(project);
await writeState(state);
}
test.beforeEach(async () => {
await setup();
await resetGoalState();
});
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test("toggleGoal publishes project goal refresh marker for the project", async () => {
const events: Array<{ event: string; payload: { projectId?: string; note?: string } }> = [];
const unsubscribe = subscribeBossEvents((event, payload) => {
events.push({ event, payload });
});
await toggleGoal("project-goal-events", "goal-1");
unsubscribe();
const latest = events.at(-1);
assert.ok(latest);
assert.equal(latest.event, "conversation.updated");
assert.equal(latest.payload.projectId, "project-goal-events");
assert.equal(latest.payload.note, "project_goals.updated");
});
test("updateGoalText publishes project goal refresh marker for the project", async () => {
const events: Array<{ event: string; payload: { projectId?: string; note?: string } }> = [];
const unsubscribe = subscribeBossEvents((event, payload) => {
events.push({ event, payload });
});
await updateGoalText("project-goal-events", "goal-1", "完成接入验证并复盘");
unsubscribe();
const latest = events.at(-1);
assert.ok(latest);
assert.equal(latest.event, "conversation.updated");
assert.equal(latest.payload.projectId, "project-goal-events");
assert.equal(latest.payload.note, "project_goals.updated");
});
test("createGoal publishes project goal refresh marker for the project", async () => {
const events: Array<{ event: string; payload: { projectId?: string; note?: string } }> = [];
const unsubscribe = subscribeBossEvents((event, payload) => {
events.push({ event, payload });
});
await createGoal("project-goal-events", "补一条回归目标");
unsubscribe();
const latest = events.at(-1);
assert.ok(latest);
assert.equal(latest.event, "conversation.updated");
assert.equal(latest.payload.projectId, "project-goal-events");
assert.equal(latest.payload.note, "project_goals.updated");
});