Refresh ops center in realtime

This commit is contained in:
kris
2026-04-07 16:04:18 +08:00
parent a42e5b75dc
commit 0b0bc5152f
7 changed files with 320 additions and 5 deletions

View File

@@ -9,8 +9,15 @@ import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.LinkedHashMap;
import java.util.Map;
public class OpsCenterActivity extends BossScreenActivity {
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private LinearLayout contentRoot;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
@@ -20,9 +27,28 @@ public class OpsCenterActivity extends BossScreenActivity {
contentRoot = new LinearLayout(this);
contentRoot.setOrientation(LinearLayout.VERTICAL);
replaceContent(contentRoot);
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() {
setRefreshing(true);
@@ -42,6 +68,63 @@ public class OpsCenterActivity 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()) {
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) {
return "app.logs.updated".equals(event.eventName)
|| "project.context_risk.updated".equals(event.eventName);
}
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 render(JSONObject ops) {
replaceContent(contentRoot);
contentRoot.removeAllViews();

View File

@@ -0,0 +1,101 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
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 org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class OpsCenterActivityTest {
@Test
public void appLogsUpdatedEventTriggersReload() throws Exception {
TestOpsCenterActivity activity = Robolectric
.buildActivity(TestOpsCenterActivity.class, new Intent())
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("app.logs.updated", new JSONObject().put("deviceId", "mac-studio"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.reloadCount);
}
@Test
public void projectContextRiskUpdatedEventTriggersReload() throws Exception {
TestOpsCenterActivity activity = Robolectric
.buildActivity(TestOpsCenterActivity.class, new Intent())
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("project.context_risk.updated", new JSONObject().put("status", "verified"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.reloadCount);
}
@Test
public void unrelatedConversationEventDoesNotTriggerReload() throws Exception {
TestOpsCenterActivity activity = Robolectric
.buildActivity(TestOpsCenterActivity.class, new Intent())
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.reloadCount);
}
public static class TestOpsCenterActivity extends OpsCenterActivity {
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": 3354971,
"updatedAt": "2026-04-07T07:47:51Z",
"sha256": "09649f9fa11f5dec4192088e5fc3f6be025e42cd1519c647e80e10505e99a0b3",
"sizeBytes": 3355141,
"updatedAt": "2026-04-07T08:01:27Z",
"sha256": "2313bb47c3e54a0e22d94ededbf9735869e1918e3c75f5e2a8b9ebaade8246f0",
"versionName": "2.5.11",
"versionCode": 24,
"buildFlavor": "release"

View File

@@ -9739,7 +9739,7 @@ export async function performOta() {
}
export async function approveRepairTicket(ticketId: string) {
return mutateState((state) => {
const ticket = await mutateState((state) => {
const ticket = state.opsRepairTickets.find((item) => item.ticketId === ticketId);
if (!ticket) throw new Error("TICKET_NOT_FOUND");
ticket.approvalStatus = "approved";
@@ -9748,10 +9748,15 @@ export async function approveRepairTicket(ticketId: string) {
ticket.updatedAt = nowIso();
return ticket;
});
publishBossEvent("project.context_risk.updated", {
status: "approved",
note: ticketId,
});
return ticket;
}
export async function verifyRepairTicket(ticketId: string) {
return mutateState((state) => {
const ticket = await mutateState((state) => {
const ticket = state.opsRepairTickets.find((item) => item.ticketId === ticketId);
if (!ticket) throw new Error("TICKET_NOT_FOUND");
ticket.executionStatus = "verified";
@@ -9772,4 +9777,9 @@ export async function verifyRepairTicket(ticketId: string) {
return ticket;
});
publishBossEvent("project.context_risk.updated", {
status: "verified",
note: ticketId,
});
return ticket;
}

View File

@@ -0,0 +1,121 @@
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 approveRepairTicket: (typeof import("../src/lib/boss-data"))["approveRepairTicket"];
let verifyRepairTicket: (typeof import("../src/lib/boss-data"))["verifyRepairTicket"];
let subscribeBossEvents: (typeof import("../src/lib/boss-events"))["subscribeBossEvents"];
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-ops-events-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const data = await import("../src/lib/boss-data.ts");
const events = await import("../src/lib/boss-events.ts");
readState = data.readState;
writeState = data.writeState;
approveRepairTicket = data.approveRepairTicket;
verifyRepairTicket = data.verifyRepairTicket;
subscribeBossEvents = events.subscribeBossEvents;
}
async function resetOpsState() {
const state = await readState();
state.opsFaults = [
{
faultId: "fault-win-camera",
faultKey: "WIN_CAMERA_UPLOAD_DELAY",
severity: "high",
status: "open",
nodeId: "mac-studio",
serviceName: "boss-local-agent",
projectId: "master-agent",
threadRef: "thread-ops-1",
traceId: "trace-ops-1",
runbookId: "runbook-camera-sync",
firstSeenAt: "2026-03-25T11:34:00+08:00",
lastSeenAt: "2026-03-25T11:58:00+08:00",
summary: "摄像头证据上传延迟。",
suggestedNextAction: "重试本地 agent 上传。",
autoRepairable: true,
},
];
state.opsRepairTickets = [
{
ticketId: "ticket-win-camera",
faultId: "fault-win-camera",
title: "重试摄像头证据上传",
approvalStatus: "approved",
executionStatus: "running",
requestedBy: "运维 Agent",
approvedBy: "主 Agent",
targetNodeId: "mac-studio",
actionSummary: "重新触发本地 agent 心跳和摄像头证据上传。",
resultSummary: "等待复验中。",
createdAt: "2026-03-25T11:42:00+08:00",
updatedAt: "2026-03-25T11:57:00+08:00",
},
];
state.opsRepairVerifications = [
{
verificationId: "verify-win-camera",
ticketId: "ticket-win-camera",
verifier: "运维审计 Agent",
status: "watching",
summary: "等待关键帧落库后关闭工单。",
verifiedAt: "2026-03-25T11:58:00+08:00",
},
];
await writeState(state);
}
test.beforeEach(async () => {
await setup();
await resetOpsState();
});
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test("approve repair ticket publishes project context risk event", async () => {
const events: Array<{ event: string; payload: { status?: string; note?: string } }> = [];
const unsubscribe = subscribeBossEvents((event, payload) => {
events.push({ event, payload });
});
await approveRepairTicket("ticket-win-camera");
unsubscribe();
const latest = events.at(-1);
assert.ok(latest);
assert.equal(latest.event, "project.context_risk.updated");
assert.equal(latest.payload.status, "approved");
assert.equal(latest.payload.note, "ticket-win-camera");
});
test("verify repair ticket publishes project context risk event", async () => {
const events: Array<{ event: string; payload: { status?: string; note?: string } }> = [];
const unsubscribe = subscribeBossEvents((event, payload) => {
events.push({ event, payload });
});
await verifyRepairTicket("ticket-win-camera");
unsubscribe();
const latest = events.at(-1);
assert.ok(latest);
assert.equal(latest.event, "project.context_risk.updated");
assert.equal(latest.payload.status, "verified");
assert.equal(latest.payload.note, "ticket-win-camera");
});