diff --git a/android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java b/android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java index f06bf91..229c427 100644 --- a/android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java @@ -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 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> iterator = recentRealtimeEventTimestamps.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) { + iterator.remove(); + } + } + } + private void render(JSONObject ops) { replaceContent(contentRoot); contentRoot.removeAllViews(); diff --git a/android/app/src/test/java/com/hyzq/boss/OpsCenterActivityTest.java b/android/app/src/test/java/com/hyzq/boss/OpsCenterActivityTest.java new file mode 100644 index 0000000..5949feb --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/OpsCenterActivityTest.java @@ -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); + } + } +} diff --git a/public/downloads/boss-android-latest.apk b/public/downloads/boss-android-latest.apk index 6140d6d..7bd8e34 100644 Binary files a/public/downloads/boss-android-latest.apk and b/public/downloads/boss-android-latest.apk differ diff --git a/public/downloads/boss-android-latest.json b/public/downloads/boss-android-latest.json index b83033b..e71719b 100644 --- a/public/downloads/boss-android-latest.json +++ b/public/downloads/boss-android-latest.json @@ -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" diff --git a/public/downloads/boss-android-v2.5.11-release.apk b/public/downloads/boss-android-v2.5.11-release.apk index 6140d6d..7bd8e34 100644 Binary files a/public/downloads/boss-android-v2.5.11-release.apk and b/public/downloads/boss-android-v2.5.11-release.apk differ diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index f818e24..b02425b 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -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; } diff --git a/tests/ops-repair-ticket-events.test.ts b/tests/ops-repair-ticket-events.test.ts new file mode 100644 index 0000000..2ea31fe --- /dev/null +++ b/tests/ops-repair-ticket-events.test.ts @@ -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"); +});