mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
chore(identity): merge main and renumber migrations
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
93
desktop/src/shared/api/relayAuthPolicy.test.mjs
Normal file
93
desktop/src/shared/api/relayAuthPolicy.test.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
AuthOkTracker,
|
||||
MAX_CONSECUTIVE_AUTH_REJECTIONS,
|
||||
} from "./relayAuthPolicy.ts";
|
||||
|
||||
test("success resolves authenticated and resets the streak", () => {
|
||||
const tracker = new AuthOkTracker();
|
||||
tracker.record(false, "auth-required: verification failed");
|
||||
tracker.record(false, "auth-required: verification failed");
|
||||
assert.equal(tracker.record(true, ""), "authenticated");
|
||||
// Streak reset: the next rejection starts a fresh count.
|
||||
assert.equal(
|
||||
tracker.record(false, "auth-required: verification failed"),
|
||||
"retry",
|
||||
);
|
||||
});
|
||||
|
||||
test("already-authenticated rejection is treated as authenticated (duplicate-AUTH race)", () => {
|
||||
const tracker = new AuthOkTracker();
|
||||
assert.equal(
|
||||
tracker.record(false, "auth-required: already authenticated"),
|
||||
"authenticated",
|
||||
);
|
||||
});
|
||||
|
||||
test("restricted rejections latch terminal immediately", () => {
|
||||
for (const message of [
|
||||
"restricted: not a relay member",
|
||||
"restricted: you are banned",
|
||||
]) {
|
||||
assert.equal(new AuthOkTracker().record(false, message), "terminal");
|
||||
}
|
||||
});
|
||||
|
||||
test("the relay's actual ban string latches terminal immediately", () => {
|
||||
// Exact string emitted by crates/buzz-relay/src/handlers/auth.rs at the
|
||||
// ban seam. A known-permanent ban must never enter the retry loop.
|
||||
assert.equal(
|
||||
new AuthOkTracker().record(
|
||||
false,
|
||||
"blocked: you are banned from this community",
|
||||
),
|
||||
"terminal",
|
||||
);
|
||||
});
|
||||
|
||||
test("verification failures retry with backoff (clock skew, DB fail-closed)", () => {
|
||||
const tracker = new AuthOkTracker();
|
||||
assert.equal(
|
||||
tracker.record(false, "auth-required: verification failed"),
|
||||
"retry",
|
||||
);
|
||||
});
|
||||
|
||||
test("unknown rejection reasons retry rather than latch", () => {
|
||||
assert.equal(new AuthOkTracker().record(false, ""), "retry");
|
||||
assert.equal(new AuthOkTracker().record(false, "error: internal"), "retry");
|
||||
});
|
||||
|
||||
test("consecutive rejections latch terminal at the cap", () => {
|
||||
const tracker = new AuthOkTracker();
|
||||
let decision = "retry";
|
||||
for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS; i++) {
|
||||
decision = tracker.record(false, "auth-required: verification failed");
|
||||
}
|
||||
assert.equal(decision, "terminal");
|
||||
});
|
||||
|
||||
test("reset grants a fresh retry streak after explicit re-engagement", () => {
|
||||
const tracker = new AuthOkTracker();
|
||||
for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS; i++) {
|
||||
tracker.record(false, "auth-required: verification failed");
|
||||
}
|
||||
tracker.reset();
|
||||
assert.equal(
|
||||
tracker.record(false, "auth-required: verification failed"),
|
||||
"retry",
|
||||
);
|
||||
});
|
||||
|
||||
test("already-authenticated wins even past the cap (session is usable)", () => {
|
||||
const tracker = new AuthOkTracker();
|
||||
for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS + 1; i++) {
|
||||
tracker.record(false, "auth-required: verification failed");
|
||||
}
|
||||
assert.equal(
|
||||
tracker.record(false, "auth-required: already authenticated"),
|
||||
"authenticated",
|
||||
);
|
||||
});
|
||||
65
desktop/src/shared/api/relayAuthPolicy.ts
Normal file
65
desktop/src/shared/api/relayAuthPolicy.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Policy for NIP-42 AUTH `OK` responses (G2 of the CMD+R gap audit).
|
||||
*
|
||||
* Historically ANY auth `OK false` latched the session terminal — no
|
||||
* reconnect until explicit user re-engagement. But the relay sends
|
||||
* `OK false` for conditions that are transient from the client's side:
|
||||
*
|
||||
* - `auth-required: already authenticated` — a duplicate/late AUTH event on
|
||||
* a connection that is in fact authenticated. The session is usable;
|
||||
* treat it as authenticated.
|
||||
* - `auth-required: verification failed` — covers ±60s clock-skew rejects
|
||||
* and the relay's fail-closed allowlist DB lookup errors, both of which
|
||||
* can clear on retry.
|
||||
*
|
||||
* Only `restricted:` and `blocked:` rejections (not a relay member / banned)
|
||||
* are known permanent. Everything else retries with normal backoff, but
|
||||
* latches terminal after `MAX_CONSECUTIVE_AUTH_REJECTIONS` consecutive
|
||||
* rejections so a genuinely broken identity (e.g. persistently wrong system
|
||||
* clock) still surfaces the terminal error card instead of flapping forever.
|
||||
*
|
||||
* The rejection streak is preserved across environment-driven resume
|
||||
* attempts (focus/online/visibility); only explicit user re-engagement —
|
||||
* the reconnect card or a community switch — may reset it.
|
||||
*/
|
||||
export type AuthOkDecision = "authenticated" | "retry" | "terminal";
|
||||
|
||||
export const MAX_CONSECUTIVE_AUTH_REJECTIONS = 3;
|
||||
|
||||
/** Tracks consecutive AUTH rejections across reconnect attempts. */
|
||||
export class AuthOkTracker {
|
||||
private consecutiveRejections = 0;
|
||||
|
||||
/**
|
||||
* Record an AUTH `OK` and decide the session's next move.
|
||||
* A success — real or "already authenticated" — resets the streak.
|
||||
*/
|
||||
record(success: boolean, message: string): AuthOkDecision {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
if (
|
||||
success ||
|
||||
normalized.startsWith("auth-required: already authenticated")
|
||||
) {
|
||||
this.consecutiveRejections = 0;
|
||||
return "authenticated";
|
||||
}
|
||||
|
||||
this.consecutiveRejections++;
|
||||
|
||||
if (
|
||||
normalized.startsWith("restricted:") ||
|
||||
normalized.startsWith("blocked:")
|
||||
) {
|
||||
return "terminal";
|
||||
}
|
||||
if (this.consecutiveRejections >= MAX_CONSECUTIVE_AUTH_REJECTIONS) {
|
||||
return "terminal";
|
||||
}
|
||||
return "retry";
|
||||
}
|
||||
|
||||
/** Called on explicit re-engagement (disconnect / manual preconnect). */
|
||||
reset(): void {
|
||||
this.consecutiveRejections = 0;
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,12 @@ const IDS = [
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
];
|
||||
|
||||
test("huddle TTS filter is future-only for both message kinds", () => {
|
||||
assert.deepEqual(buildHuddleTtsLiveFilter(CHANNEL), {
|
||||
test("huddle TTS filter includes a bounded startup replay for both message kinds", () => {
|
||||
assert.deepEqual(buildHuddleTtsLiveFilter(CHANNEL, 1_725_100_000), {
|
||||
kinds: [9, 40002],
|
||||
"#h": [CHANNEL],
|
||||
limit: 0,
|
||||
since: 1_725_100_000,
|
||||
limit: 50,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,14 +42,23 @@ export function buildChannelFilter(
|
||||
return filter;
|
||||
}
|
||||
|
||||
/** Strictly live huddle message filter: zero stored rows, future messages only. */
|
||||
/**
|
||||
* Huddle TTS message filter with a bounded startup replay window.
|
||||
*
|
||||
* The Huddle window and agent membership snapshot can finish mounting just
|
||||
* after the first agent reply is stored. Replaying only the caller-provided
|
||||
* startup window closes that race; event-id dedup in the consumer prevents a
|
||||
* stored row from being spoken twice when it is also delivered live.
|
||||
*/
|
||||
export function buildHuddleTtsLiveFilter(
|
||||
channelId: string,
|
||||
since: number,
|
||||
): RelaySubscriptionFilter {
|
||||
return {
|
||||
kinds: [KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2],
|
||||
"#h": [channelId],
|
||||
limit: 0,
|
||||
since,
|
||||
limit: 50,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEm
|
||||
import {
|
||||
isServiceRestartClose,
|
||||
isWebSocketClose,
|
||||
isWebSocketError,
|
||||
shouldRefuseConnect,
|
||||
shouldScheduleReconnect,
|
||||
shouldWaitForScheduledReconnect,
|
||||
@@ -65,6 +66,7 @@ import {
|
||||
STALL_IDLE_TIMEOUT_MS,
|
||||
} from "@/shared/api/relayClientTimings";
|
||||
import { closeWebSocket } from "@/shared/api/relayWebSocketClose";
|
||||
import { AuthOkTracker } from "@/shared/api/relayAuthPolicy";
|
||||
import { buildThreadReferenceTags } from "@/features/messages/lib/threading";
|
||||
|
||||
export class RelayClient {
|
||||
@@ -92,6 +94,7 @@ export class RelayClient {
|
||||
private connectionGeneration = 0;
|
||||
private stabilityTimer: number | null = null;
|
||||
private visibleChannelId: string | null = null;
|
||||
private authOkTracker = new AuthOkTracker();
|
||||
|
||||
private terminal = false;
|
||||
|
||||
@@ -128,6 +131,7 @@ export class RelayClient {
|
||||
this.notifyReconnectListeners = false;
|
||||
this.terminal = false;
|
||||
this.visibleChannelId = null;
|
||||
this.authOkTracker.reset();
|
||||
this.connectionStateEmitter.set("idle");
|
||||
|
||||
if (this.wsId !== null) {
|
||||
@@ -295,7 +299,7 @@ export class RelayClient {
|
||||
parentEventId?: string | null,
|
||||
rootEventId?: string | null,
|
||||
) {
|
||||
// Bail when disconnected — not worth triggering a reconnect for ephemeral typing events.
|
||||
// Disconnected: not worth triggering a reconnect for ephemeral typing.
|
||||
if (this.wsId === null) {
|
||||
return;
|
||||
}
|
||||
@@ -325,11 +329,11 @@ export class RelayClient {
|
||||
channelId: string,
|
||||
onEvent: (event: RelayEvent) => void,
|
||||
) {
|
||||
// 39005 rides only this window-store subscription — CHANNEL_EVENT_KINDS'
|
||||
// other consumers (unread tracking, cache merges) must never see
|
||||
// summary overlays.
|
||||
return this.subscribe(
|
||||
{
|
||||
// 39005 rides only this window-store subscription — not
|
||||
// CHANNEL_EVENT_KINDS, whose other consumers (unread tracking,
|
||||
// timeline-cache merges) must never see summary overlays.
|
||||
kinds: [...CHANNEL_EVENT_KINDS, KIND_CHANNEL_THREAD_SUMMARY],
|
||||
"#h": [channelId],
|
||||
limit: 1000,
|
||||
@@ -340,10 +344,9 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to huddle lifecycle events (kinds 48100–48103) for a channel.
|
||||
* Used by HuddleIndicator to detect active huddles without being drowned
|
||||
* out by regular channel messages in the generic subscription window.
|
||||
* Includes both historical (last 10) and live events.
|
||||
* Subscribe to huddle lifecycle events (kinds 48100–48103) for a channel,
|
||||
* so HuddleIndicator detects active huddles without being drowned out by
|
||||
* regular channel messages. Includes the last 10 historical events.
|
||||
*/
|
||||
async subscribeToHuddleEvents(
|
||||
channelId: string,
|
||||
@@ -425,12 +428,26 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
async preconnect() {
|
||||
// Explicit re-engagement. If the session went terminal (auth rejection)
|
||||
// the caller is asking us to try again, so clear the latch. A manual
|
||||
// reconnect also bypasses the current delay once; ordinary operations do
|
||||
// not, so background traffic cannot continuously defeat backoff.
|
||||
// Explicit re-engagement (reconnect card / community switch): clears the
|
||||
// terminal latch and AUTH rejection streak, and bypasses backoff once.
|
||||
this.terminal = false;
|
||||
this.authOkTracker.reset();
|
||||
this.keepAliveRequested = true;
|
||||
await this.connectBypassingBackoff();
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment-driven resume (online/focus/visibility): bypasses a pending
|
||||
* backoff timer but preserves the terminal latch and AUTH rejection streak
|
||||
* — only `preconnect()` clears those, so resume events during repeated
|
||||
* AUTH rejection cannot defeat the consecutive-rejection cap.
|
||||
*/
|
||||
async resumeReconnect() {
|
||||
if (this.terminal) return;
|
||||
await this.connectBypassingBackoff();
|
||||
}
|
||||
|
||||
private async connectBypassingBackoff() {
|
||||
if (this.reconnectTimeout !== null) {
|
||||
window.clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
@@ -448,7 +465,6 @@ export class RelayClient {
|
||||
|
||||
subscribeToReconnects(listener: () => void) {
|
||||
this.reconnectListeners.add(listener);
|
||||
|
||||
return () => {
|
||||
this.reconnectListeners.delete(listener);
|
||||
};
|
||||
@@ -460,8 +476,8 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to connection-state transitions. The listener is invoked
|
||||
* immediately with the current state so callers don't need a separate
|
||||
* Subscribe to connection-state transitions. The listener fires
|
||||
* immediately with the current state, so callers need no separate
|
||||
* `getConnectionState()` call to seed their UI.
|
||||
*/
|
||||
subscribeToConnectionState(listener: (state: ConnectionState) => void) {
|
||||
@@ -470,11 +486,10 @@ export class RelayClient {
|
||||
|
||||
private async ensureConnected() {
|
||||
if (shouldRefuseConnect({ terminal: this.terminal })) {
|
||||
// Session is terminal (e.g. relay rejected auth). Refuse to connect
|
||||
// until an explicit re-engagement (disconnect()/preconnect()) clears
|
||||
// the flag. Without this, the reconnect timer's catch handler — and
|
||||
// the retry wrappers in publishEvent / sendRawWithReconnectRetry —
|
||||
// would race the terminal "disconnected" state back to "reconnecting".
|
||||
// Terminal (e.g. relay rejected auth): refuse until disconnect() or
|
||||
// preconnect() clears the latch, else the reconnect-timer catch and
|
||||
// the publish/subscribe retry wrappers would race the terminal
|
||||
// "disconnected" state back to "reconnecting".
|
||||
throw new Error("Relay session is terminal; cannot reconnect.");
|
||||
}
|
||||
|
||||
@@ -494,7 +509,7 @@ export class RelayClient {
|
||||
// The reconnect coordinator owns outage pacing. Query, publish, and
|
||||
// subscription callers must wait for its scheduled attempt instead of
|
||||
// clearing the timer and creating an immediate reconnect storm.
|
||||
return this.waitForScheduledReconnect();
|
||||
return this.reconnectWaiters.wait();
|
||||
}
|
||||
|
||||
const connectPromise = this.connect();
|
||||
@@ -670,7 +685,6 @@ export class RelayClient {
|
||||
error,
|
||||
fallbackMessage,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
await this.sendRaw(payload);
|
||||
@@ -749,12 +763,7 @@ export class RelayClient {
|
||||
this.resetConnection(new Error("Relay connection closed."));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"type" in message &&
|
||||
message.type === "Error"
|
||||
) {
|
||||
if (isWebSocketError(message)) {
|
||||
this.resetConnection(new Error("Relay connection errored."));
|
||||
return;
|
||||
}
|
||||
@@ -819,8 +828,7 @@ export class RelayClient {
|
||||
|
||||
if (type === "NOTICE" && typeof rest[0] === "string") {
|
||||
const notice: string = rest[0];
|
||||
// Relay back-pressure signal — activate the gate so pending operations
|
||||
// back off until the window expires.
|
||||
// Relay back-pressure — arm the gate until the window expires.
|
||||
if (notice.startsWith("rate-limited:")) {
|
||||
activateRateLimit(parseRateLimitHint(notice));
|
||||
}
|
||||
@@ -892,12 +900,14 @@ export class RelayClient {
|
||||
const authRequest = this.authRequest;
|
||||
this.authRequest = null;
|
||||
|
||||
if (success) {
|
||||
// Decision table lives in relayAuthPolicy.ts.
|
||||
const decision = this.authOkTracker.record(success, message);
|
||||
if (decision === "authenticated") {
|
||||
authRequest.resolve();
|
||||
} else {
|
||||
const error = new Error(message || "Relay authentication rejected.");
|
||||
authRequest.reject(error);
|
||||
this.resetConnection(error, { reconnect: false });
|
||||
this.resetConnection(error, { reconnect: decision === "retry" });
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -919,13 +929,7 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
private hasLiveSubscriptions() {
|
||||
for (const subscription of this.subscriptions.values()) {
|
||||
if (subscription.mode === "live") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return [...this.subscriptions.values()].some((s) => s.mode === "live");
|
||||
}
|
||||
|
||||
private async replayLiveSubscriptions() {
|
||||
@@ -948,13 +952,6 @@ export class RelayClient {
|
||||
}
|
||||
}
|
||||
|
||||
private waitForScheduledReconnect(): Promise<void> {
|
||||
if (this.reconnectTimeout === null) {
|
||||
return this.ensureConnected();
|
||||
}
|
||||
return this.reconnectWaiters.wait();
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (
|
||||
!shouldScheduleReconnect({
|
||||
@@ -968,9 +965,8 @@ export class RelayClient {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply ±25% jitter so a fleet of clients reconnecting simultaneously
|
||||
// spreads their AUTH storms across a 50% window instead of all hitting
|
||||
// the relay at the same instant.
|
||||
// ±25% jitter spreads a fleet's AUTH storms across a 50% window instead
|
||||
// of hitting the relay at the same instant.
|
||||
const jitter = this.reconnectDelayMs * (0.75 + Math.random() * 0.5);
|
||||
const delay = Math.min(jitter, RECONNECT_MAX_DELAY_MS);
|
||||
this.reconnectDelayMs = Math.min(
|
||||
@@ -1031,9 +1027,13 @@ export class RelayClient {
|
||||
if (options?.reconnect === false) {
|
||||
this.terminal = true;
|
||||
this.connectionStateEmitter.set("disconnected");
|
||||
} else if (this.connectionStateEmitter.get() !== "stalled") {
|
||||
// Stall is a stronger signal than a generic drop; keep it until the
|
||||
// reconnect timer transitions us back to "reconnecting" in connect().
|
||||
} else if (
|
||||
// A late retry failure racing a terminal latch must not paint
|
||||
// "reconnecting" over the terminal "disconnected" state; stall is a
|
||||
// stronger signal than a generic drop and is kept until reconnect.
|
||||
!this.terminal &&
|
||||
this.connectionStateEmitter.get() !== "stalled"
|
||||
) {
|
||||
this.connectionStateEmitter.set("reconnecting");
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ test("classifyRelayClosed: terminal messages return terminal", () => {
|
||||
for (const message of [
|
||||
"restricted: not a channel member",
|
||||
"restricted: channel access revoked",
|
||||
"auth-required: not authenticated",
|
||||
"blocked: banned",
|
||||
"invalid: malformed filter",
|
||||
"pow: difficulty too low",
|
||||
@@ -38,6 +37,21 @@ test("classifyRelayClosed: transient errors return retryable", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("classifyRelayClosed: auth-required is retryable (REQ/AUTH reconnect race)", () => {
|
||||
// A REQ that lands before the AUTH handshake completes gets CLOSED with
|
||||
// auth-required. Deleting the subscription would silently freeze the
|
||||
// channel while the connection state still reads "connected" — the sub
|
||||
// must survive and be retried after backoff.
|
||||
assert.equal(
|
||||
classifyRelayClosed("auth-required: not authenticated"),
|
||||
"retryable",
|
||||
);
|
||||
assert.equal(
|
||||
classifyRelayClosed("auth-required: authenticate before subscribing"),
|
||||
"retryable",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Subscription-survival semantics ──────────────────────────────────────────
|
||||
// These replace the removed isRetryableRelayClosed wrapper tests.
|
||||
// rate-limited must not delete the subscription; terminal must.
|
||||
@@ -59,7 +73,6 @@ test("classifyRelayClosed: retryable class survives (subscription must not be de
|
||||
test("classifyRelayClosed: terminal class triggers deletion (no retry)", () => {
|
||||
for (const message of [
|
||||
"restricted: not a channel member",
|
||||
"auth-required: not authenticated",
|
||||
"blocked: banned",
|
||||
"invalid: malformed filter",
|
||||
"pow: difficulty too low",
|
||||
|
||||
@@ -19,9 +19,13 @@ export function classifyRelayClosed(message: string): RelayClosedClass {
|
||||
if (normalized.startsWith("rate-limited:")) {
|
||||
return "rate-limited";
|
||||
}
|
||||
// `auth-required:` is deliberately retryable, NOT terminal: it occurs
|
||||
// transiently when a REQ races the AUTH handshake after a reconnect. The
|
||||
// backoff retry re-sends the REQ once the session is authenticated. A
|
||||
// session that is genuinely unauthenticated latches `terminal` at the
|
||||
// connection level (AUTH OK=false), so this cannot loop forever.
|
||||
if (
|
||||
normalized.startsWith("restricted:") ||
|
||||
normalized.startsWith("auth-required:") ||
|
||||
normalized.startsWith("blocked:") ||
|
||||
normalized.startsWith("invalid:") ||
|
||||
normalized.startsWith("pow:") ||
|
||||
|
||||
@@ -70,3 +70,13 @@ export function isServiceRestartClose(message: unknown): boolean {
|
||||
data.code === 1012
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a WS-layer message is the plugin's `Error` frame. */
|
||||
export function isWebSocketError(message: unknown): boolean {
|
||||
return (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"type" in message &&
|
||||
message.type === "Error"
|
||||
);
|
||||
}
|
||||
|
||||
70
desktop/src/shared/api/relayResumeTriggerPolicy.test.mjs
Normal file
70
desktop/src/shared/api/relayResumeTriggerPolicy.test.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
RESUME_TRIGGER_MIN_INTERVAL_MS,
|
||||
shouldTriggerResumeReconnect,
|
||||
} from "./relayResumeTriggerPolicy.ts";
|
||||
|
||||
const base = Object.freeze({
|
||||
connectionState: "reconnecting",
|
||||
lastAttemptAt: 0,
|
||||
now: RESUME_TRIGGER_MIN_INTERVAL_MS + 1,
|
||||
});
|
||||
|
||||
test("reconnecting + interval elapsed triggers", () => {
|
||||
assert.equal(shouldTriggerResumeReconnect({ ...base }), true);
|
||||
});
|
||||
|
||||
test("stalled + interval elapsed triggers", () => {
|
||||
assert.equal(
|
||||
shouldTriggerResumeReconnect({ ...base, connectionState: "stalled" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("healthy and terminal states never trigger", () => {
|
||||
for (const connectionState of [
|
||||
"idle",
|
||||
"connecting",
|
||||
"connected",
|
||||
"disconnected",
|
||||
]) {
|
||||
assert.equal(
|
||||
shouldTriggerResumeReconnect({ ...base, connectionState }),
|
||||
false,
|
||||
connectionState,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("burst of triggers within the rate window fires once", () => {
|
||||
assert.equal(
|
||||
shouldTriggerResumeReconnect({
|
||||
...base,
|
||||
lastAttemptAt: 1_000,
|
||||
now: 1_000 + RESUME_TRIGGER_MIN_INTERVAL_MS - 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldTriggerResumeReconnect({
|
||||
...base,
|
||||
lastAttemptAt: 1_000,
|
||||
now: 1_000 + RESUME_TRIGGER_MIN_INTERVAL_MS,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("custom interval override is honoured", () => {
|
||||
assert.equal(
|
||||
shouldTriggerResumeReconnect({
|
||||
...base,
|
||||
lastAttemptAt: 0,
|
||||
now: 10,
|
||||
minIntervalMs: 5,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
39
desktop/src/shared/api/relayResumeTriggerPolicy.ts
Normal file
39
desktop/src/shared/api/relayResumeTriggerPolicy.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Policy for event-driven reconnect triggers (G1 of the CMD+R gap audit).
|
||||
*
|
||||
* The exponential-backoff timer is the only thing driving recovery after an
|
||||
* outage — and WKWebView throttles JS timers in occluded/background windows,
|
||||
* so at max backoff (30s) a scheduled attempt may not fire until the user
|
||||
* focuses the window. These triggers short-circuit the wait the moment the
|
||||
* environment signals recovery: network `online`, window focus, and
|
||||
* visibility becoming visible. `preconnect()` already clears the pending
|
||||
* backoff timer, so a trigger converts "wait up to 30s (or forever, if
|
||||
* throttled)" into "reconnect now".
|
||||
*/
|
||||
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
||||
|
||||
/** Min ms between trigger-driven preconnect attempts. */
|
||||
export const RESUME_TRIGGER_MIN_INTERVAL_MS = 5_000;
|
||||
|
||||
export function shouldTriggerResumeReconnect(inputs: {
|
||||
connectionState: ConnectionState;
|
||||
lastAttemptAt: number;
|
||||
now: number;
|
||||
minIntervalMs?: number;
|
||||
}): boolean {
|
||||
const minInterval = inputs.minIntervalMs ?? RESUME_TRIGGER_MIN_INTERVAL_MS;
|
||||
|
||||
// Only degraded-but-recoverable states. `disconnected` is the terminal
|
||||
// latch — explicit user re-engagement owns that path, and `idle` /
|
||||
// `connecting` / `connected` need no help.
|
||||
if (
|
||||
inputs.connectionState !== "reconnecting" &&
|
||||
inputs.connectionState !== "stalled"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rate-limit: focus/online events arrive in bursts (e.g. wake fires all
|
||||
// three); one attempt per window is enough.
|
||||
return inputs.now - inputs.lastAttemptAt >= minInterval;
|
||||
}
|
||||
34
desktop/src/shared/api/restartDiff.ts
Normal file
34
desktop/src/shared/api/restartDiff.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Wire types for the restart-required config diff.
|
||||
*
|
||||
* These are camelCase / TS-idiomatic mirrors of the Rust `RestartDiffEntry`
|
||||
* serialized shape. The `field` is a dotted path derived from serde field
|
||||
* names (e.g. `"model"`, `"env.FOO"`); display labels are produced by
|
||||
* humanising the path — there is no per-field label map.
|
||||
*/
|
||||
|
||||
/** A JSON-compatible value. Arrays are atomic leaves in the diff. */
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* One change in a restart diff. The `kind` discriminant is a closed set of
|
||||
* five values; unknown `field` paths must render gracefully in the UI.
|
||||
*/
|
||||
export type RestartChange =
|
||||
| { kind: "value"; before: JsonValue; after: JsonValue }
|
||||
| { kind: "text"; before_chars: number | null; after_chars: number | null }
|
||||
| { kind: "masked"; before: string | null; after: string | null }
|
||||
| { kind: "added" }
|
||||
| { kind: "removed" };
|
||||
|
||||
export type RestartDiffEntry = {
|
||||
/** Dotted path derived from serde field names, e.g. "model", "env.FOO". */
|
||||
field: string;
|
||||
change: RestartChange;
|
||||
};
|
||||
@@ -118,6 +118,8 @@ type RawRelayAgent = {
|
||||
respond_to?: RelayAgent["respondTo"];
|
||||
respond_to_allowlist?: string[];
|
||||
};
|
||||
|
||||
import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff";
|
||||
export type RawManagedAgent = {
|
||||
pubkey: string;
|
||||
name: string;
|
||||
@@ -143,6 +145,7 @@ export type RawManagedAgent = {
|
||||
persona_out_of_date: boolean;
|
||||
persona_orphaned: boolean;
|
||||
needs_restart: boolean;
|
||||
restart_diff?: RawRestartDiffEntry[];
|
||||
env_vars?: Record<string, string>;
|
||||
status: ManagedAgent["status"];
|
||||
pid: number | null;
|
||||
@@ -158,8 +161,7 @@ export type RawManagedAgent = {
|
||||
auto_restart_on_config_change?: boolean;
|
||||
backend: ManagedAgentBackend;
|
||||
backend_agent_id: string | null;
|
||||
// Optional: pre-feature mock fixtures may omit these. Mapped to
|
||||
// `"owner-only"` / `[]` in `fromRawManagedAgent`.
|
||||
// Pre-feature fixtures may omit these; mapped to "owner-only"/[] in fromRawManagedAgent.
|
||||
respond_to?: ManagedAgent["respondTo"];
|
||||
respond_to_allowlist?: string[];
|
||||
};
|
||||
@@ -188,6 +190,9 @@ export type RawAcpRuntimeCatalogEntry = {
|
||||
model_env_var?: string | null;
|
||||
provider_env_var?: string | null;
|
||||
thinking_env_var?: string | null;
|
||||
max_tokens_env_var?: string | null;
|
||||
context_limit_env_var?: string | null;
|
||||
max_rounds_env_var?: string | null;
|
||||
install_hint: string;
|
||||
install_instructions_url: string;
|
||||
can_auto_install: boolean;
|
||||
@@ -199,10 +204,7 @@ export type RawAcpRuntimeCatalogEntry = {
|
||||
auth_status: AuthStatus;
|
||||
login_hint?: string;
|
||||
source: "builtin" | "preset" | "custom";
|
||||
/**
|
||||
* Definition-level env vars for `source: custom` entries.
|
||||
* Omitted/absent for builtin and preset — skipped in Rust serialization when empty.
|
||||
*/
|
||||
/** Definition-level env vars for `source: custom` entries; absent for builtin/preset. */
|
||||
definition_env?: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -596,11 +598,11 @@ export async function uploadMedia(
|
||||
isTemp,
|
||||
});
|
||||
}
|
||||
|
||||
export async function pickAndUploadMedia(): Promise<BlobDescriptor[]> {
|
||||
return invokeTauri<BlobDescriptor[]>("pick_and_upload_media", {});
|
||||
export async function pickAndUploadMedia(
|
||||
progressId?: string,
|
||||
): Promise<BlobDescriptor[]> {
|
||||
return invokeTauri<BlobDescriptor[]>("pick_and_upload_media", { progressId });
|
||||
}
|
||||
|
||||
export async function uploadMediaBytes(
|
||||
data: number[],
|
||||
filename?: string,
|
||||
@@ -707,11 +709,11 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
|
||||
avatarUrl: agent.avatar_url ?? null,
|
||||
model: agent.model,
|
||||
modelSource: agent.model_source ?? null,
|
||||
// Fallbacks for pre-feature mocks/fixtures. Real records always carry them.
|
||||
provider: agent.provider ?? null,
|
||||
personaOutOfDate: agent.persona_out_of_date ?? false,
|
||||
personaOrphaned: agent.persona_orphaned ?? false,
|
||||
needsRestart: agent.needs_restart ?? false,
|
||||
restartDiff: agent.restart_diff ?? [],
|
||||
envVars: agent.env_vars ?? {},
|
||||
status: agent.status,
|
||||
pid: agent.pid,
|
||||
@@ -727,8 +729,6 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
|
||||
autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true,
|
||||
backend: agent.backend,
|
||||
backendAgentId: agent.backend_agent_id,
|
||||
// Fallbacks for pre-feature mocks/fixtures that don't carry these fields.
|
||||
// Real agent records always include them (defaulted server-side).
|
||||
respondTo: agent.respond_to ?? "owner-only",
|
||||
respondToAllowlist: agent.respond_to_allowlist ?? [],
|
||||
};
|
||||
@@ -749,6 +749,9 @@ export function fromRawAcpRuntimeCatalogEntry(
|
||||
modelEnvVar: entry.model_env_var ?? null,
|
||||
providerEnvVar: entry.provider_env_var ?? null,
|
||||
thinkingEnvVar: entry.thinking_env_var ?? null,
|
||||
maxTokensEnvVar: entry.max_tokens_env_var ?? null,
|
||||
contextLimitEnvVar: entry.context_limit_env_var ?? null,
|
||||
maxRoundsEnvVar: entry.max_rounds_env_var ?? null,
|
||||
installHint: entry.install_hint,
|
||||
installInstructionsUrl: entry.install_instructions_url,
|
||||
canAutoInstall: entry.can_auto_install,
|
||||
@@ -1024,9 +1027,8 @@ export type RuntimeFileConfigSubset = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the file-layer config for a runtime so dialogs can show
|
||||
* "Set in goose config" instead of surfacing a false required-field marker.
|
||||
* Returns `null` when the runtime has no config file or it cannot be parsed.
|
||||
* Get the file-layer config for a runtime so dialogs can show "Set in goose config" instead of
|
||||
* surfacing a false required-field marker. Returns `null` when unavailable or unparseable.
|
||||
*/
|
||||
export async function getRuntimeFileConfig(
|
||||
runtimeId: string,
|
||||
@@ -1040,13 +1042,9 @@ export async function getRuntimeFileConfig(
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the key names of all non-empty baked build env vars.
|
||||
*
|
||||
* Internal (Block) builds bake provider credentials into the binary at compile
|
||||
* time. This returns the *key names only* — never the values — so dialogs can
|
||||
* treat them as satisfied without exposing secrets to the frontend.
|
||||
*
|
||||
* OSS builds return an empty array (no baked env).
|
||||
* Return the key names of all non-empty baked build env vars. Internal (Block) builds bake
|
||||
* provider credentials into the binary at compile time; this returns *key names only* (never
|
||||
* values) so dialogs treat them as satisfied without exposing secrets. OSS builds return [].
|
||||
*/
|
||||
export async function getBakedBuildEnvKeys(): Promise<string[]> {
|
||||
return invokeTauri<string[]>("get_baked_build_env_keys");
|
||||
|
||||
@@ -88,23 +88,22 @@ function decodeRawSubscription(raw: RawSaveSubscription): SaveSubscription {
|
||||
// ── API wrappers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns `true` when observer-feed archive policy is enforced.
|
||||
* Returns `true` when observer-feed archive is enabled by default.
|
||||
*
|
||||
* Internal builds set `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` at build time;
|
||||
* OSS builds never set it, so this returns `false`. The frontend calls this
|
||||
* every startup to decide whether to reconcile the `owner_p` subscription.
|
||||
* Always returns `true` — archive defaults to enabled for all builds.
|
||||
* The frontend calls this every startup to decide whether to reconcile
|
||||
* the `owner_p` subscription for kind 24200 (observer frames).
|
||||
*/
|
||||
export async function observerArchiveDefaultEnabled(): Promise<boolean> {
|
||||
return invokeTauri<boolean>("observer_archive_default_enabled");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when the build has agent-turn-metric archive default-on.
|
||||
* Returns `true` when agent-turn-metric archive is enabled by default.
|
||||
*
|
||||
* Internal builds set `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` at build time;
|
||||
* OSS builds never set it, so this returns `false`. The frontend calls this
|
||||
* once at startup to decide whether to auto-seed an `owner_p` [44200]
|
||||
* subscription.
|
||||
* Always returns `true` — archive defaults to enabled for all builds.
|
||||
* The frontend calls this once at startup to decide whether to auto-seed
|
||||
* an `owner_p` [44200] subscription for new identities.
|
||||
*/
|
||||
export async function agentMetricArchiveDefaultEnabled(): Promise<boolean> {
|
||||
return invokeTauri<boolean>("agent_metric_archive_default_enabled");
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
import { invoke as invokeTauriRaw } from "@tauri-apps/api/core";
|
||||
import { type BlobDescriptor, invokeTauri } from "./tauri";
|
||||
|
||||
function encodeRawIpcHeader(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return window
|
||||
.btoa(binary)
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/** Transfer a browser File to Rust as a raw IPC body, avoiding JSON expansion. */
|
||||
export async function uploadMediaFile(
|
||||
file: File,
|
||||
progressId?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BlobDescriptor> {
|
||||
const headers: Record<string, string> = {
|
||||
"x-buzz-filename": encodeRawIpcHeader(file.name),
|
||||
};
|
||||
if (progressId) {
|
||||
headers["x-buzz-progress-id"] = encodeRawIpcHeader(progressId);
|
||||
}
|
||||
|
||||
if (signal?.aborted) throw new Error("upload cancelled");
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (signal?.aborted) throw new Error("upload cancelled");
|
||||
|
||||
return invokeTauriRaw<BlobDescriptor>("upload_media_bytes_raw", bytes, {
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop the native HTTP request associated with a background media upload. */
|
||||
export async function cancelMediaUpload(progressId: string): Promise<void> {
|
||||
await invokeTauri("cancel_media_upload", { progressId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a native single-file picker constrained to images and upload the
|
||||
* chosen file. Non-image files are rejected in Rust (via MIME sniffing)
|
||||
|
||||
@@ -260,11 +260,32 @@ export type MintedAgentCard = {
|
||||
export const NO_OPENAI_KEY_PREFIX = "NO_OPENAI_KEY:";
|
||||
|
||||
/**
|
||||
* Check whether an OpenAI key would resolve for a card mint of this agent
|
||||
* (same env layering as the mint itself). Never returns the key.
|
||||
* Which env layer resolves the OpenAI key for a card mint of this agent.
|
||||
*
|
||||
* - `"none"` — no key configured anywhere; show the first-time setup panel.
|
||||
* - `"global"` — key comes from global Agent Defaults env; writable from the
|
||||
* dialog via `cardMintSaveOpenaiKey`.
|
||||
* - `"persona"` — key is set on the linked persona; cannot be updated from
|
||||
* the mint dialog (would be shadowed by the higher-priority layer).
|
||||
* - `"agent"` — key is set directly on the agent record; same restriction.
|
||||
* - `"process"` — key comes from the process environment (dev fallback);
|
||||
* same restriction.
|
||||
*/
|
||||
export async function cardMintKeyStatus(id: string): Promise<boolean> {
|
||||
return invokeTauri<boolean>("card_mint_key_status", { id });
|
||||
export type CardMintKeyLayer =
|
||||
| "none"
|
||||
| "global"
|
||||
| "persona"
|
||||
| "agent"
|
||||
| "process";
|
||||
|
||||
/**
|
||||
* Report which env layer resolves the OpenAI key for a card mint of this agent
|
||||
* (same layering as the mint itself). Returns a layer discriminant — never the
|
||||
* key value itself. Use to decide whether to show a writable key input (none /
|
||||
* global) or a read-only redirect (persona / agent / process).
|
||||
*/
|
||||
export async function cardMintKeyStatus(id: string): Promise<CardMintKeyLayer> {
|
||||
return invokeTauri<CardMintKeyLayer>("card_mint_key_status", { id });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -310,6 +310,8 @@ export type ManagedAgentBackend =
|
||||
| { type: "local" }
|
||||
| { type: "provider"; id: string; config: Record<string, unknown> };
|
||||
|
||||
import type { RestartDiffEntry } from "./restartDiff";
|
||||
export type { JsonValue, RestartChange, RestartDiffEntry } from "./restartDiff";
|
||||
export type ManagedAgent = {
|
||||
pubkey: string;
|
||||
name: string;
|
||||
@@ -363,6 +365,8 @@ export type ManagedAgent = {
|
||||
* Always `false` for stopped agents.
|
||||
*/
|
||||
needsRestart: boolean;
|
||||
/** Non-empty iff `needsRestart` is true. Empty when Rust omits the field. */
|
||||
restartDiff: RestartDiffEntry[];
|
||||
/** Per-agent env vars. Layered on top of persona envVars. */
|
||||
envVars: Record<string, string>;
|
||||
status: "running" | "stopped" | "deployed" | "not_deployed";
|
||||
@@ -388,11 +392,7 @@ export type ManagedAgent = {
|
||||
respondToAllowlist: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Inbound author gate mode. Mirrors `buzz-acp`'s `--respond-to` CLI flag.
|
||||
* `"nobody"` is supported by the harness but not surfaced through this API —
|
||||
* it's a heartbeat-only mode without a meaningful GUI use case.
|
||||
*/
|
||||
/** Inbound author gate mode. Mirrors buzz-acp's --respond-to CLI flag. */
|
||||
export type RespondToMode = "owner-only" | "allowlist" | "anyone";
|
||||
|
||||
export type BackendProviderCandidate = {
|
||||
@@ -523,6 +523,9 @@ export type AcpRuntimeCatalogEntry = {
|
||||
providerEnvVar: string | null;
|
||||
/** Environment variable used to apply thinking effort, when supported. */
|
||||
thinkingEnvVar: string | null;
|
||||
maxTokensEnvVar: string | null;
|
||||
contextLimitEnvVar: string | null;
|
||||
maxRoundsEnvVar: string | null;
|
||||
installHint: string;
|
||||
installInstructionsUrl: string;
|
||||
canAutoInstall: boolean;
|
||||
@@ -535,12 +538,7 @@ export type AcpRuntimeCatalogEntry = {
|
||||
authStatus: AuthStatus;
|
||||
/** Hint for completing authentication; null when not applicable or already logged in. */
|
||||
loginHint: string | null;
|
||||
/**
|
||||
* Whether this entry is compiled into the app ("builtin"), a bundled preset
|
||||
* ("preset" — PATH-probed, not editable/deletable), or loaded from a user
|
||||
* JSON file in `custom_harnesses/` ("custom"). Controls editability in the
|
||||
* UI — only "custom" entries can be edited or deleted.
|
||||
*/
|
||||
/** "builtin" (compiled in), "preset" (PATH-probed, not editable), or "custom" (user JSON). Controls UI editability. */
|
||||
source: "builtin" | "preset" | "custom";
|
||||
/**
|
||||
* Definition-level environment variables for `source: custom` entries.
|
||||
@@ -608,7 +606,6 @@ export type AgentModelInfo = {
|
||||
};
|
||||
|
||||
// ── Config bridge types ──────────────────────────────────────────────────────
|
||||
|
||||
export type ConfigOrigin =
|
||||
| "buzzExplicit"
|
||||
| "acpNativeRead"
|
||||
@@ -618,7 +615,8 @@ export type ConfigOrigin =
|
||||
| "personaDefault"
|
||||
| "globalDefault"
|
||||
| "runtimeOverride"
|
||||
| "harnessConstraint";
|
||||
| "harnessConstraint"
|
||||
| "harnessDefault";
|
||||
|
||||
export type ConfigWriteMechanism =
|
||||
| { type: "respawnWithEnvVar"; envKey: string }
|
||||
|
||||
@@ -2,12 +2,12 @@ import * as React from "react";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
||||
import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import {
|
||||
isRelayConnectionDegraded,
|
||||
useRelayConnection,
|
||||
} from "@/shared/api/useRelayConnection";
|
||||
type ConnectionState,
|
||||
} from "@/shared/api/relayClientShared";
|
||||
import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation";
|
||||
import {
|
||||
isRateLimited,
|
||||
waitForRateLimit,
|
||||
@@ -100,8 +100,6 @@ export class RelayAutoHealScheduler {
|
||||
*/
|
||||
export function useRelayAutoHeal(): void {
|
||||
const queryClient = useQueryClient();
|
||||
const connectionState = useRelayConnection();
|
||||
const prevConnectionStateRef = React.useRef(connectionState);
|
||||
const schedulerRef = React.useRef<RelayAutoHealScheduler | null>(null);
|
||||
|
||||
if (schedulerRef.current === null) {
|
||||
@@ -129,14 +127,22 @@ export function useRelayAutoHeal(): void {
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
// Observe the RAW connection-state emitter, not the 2s-debounced
|
||||
// useRelayConnection() hook. A sub-2s flap never surfaces through the
|
||||
// debounced hook (its degraded report is cancelled by the recovery), yet
|
||||
// resetConnection() already rejected every in-flight query — the heal
|
||||
// must still fire or errored panes persist until a manual reconnect.
|
||||
let prev: ConnectionState | null = null;
|
||||
const unsubscribe = relayClient.subscribeToConnectionState((next) => {
|
||||
if (prev !== null) {
|
||||
schedulerRef.current?.onTransition(prev, next);
|
||||
}
|
||||
prev = next;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
schedulerRef.current?.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const prev = prevConnectionStateRef.current;
|
||||
prevConnectionStateRef.current = connectionState;
|
||||
schedulerRef.current?.onTransition(prev, connectionState);
|
||||
}, [connectionState]);
|
||||
}
|
||||
|
||||
60
desktop/src/shared/api/useRelayResumeTriggers.ts
Normal file
60
desktop/src/shared/api/useRelayResumeTriggers.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { shouldTriggerResumeReconnect } from "@/shared/api/relayResumeTriggerPolicy";
|
||||
|
||||
/**
|
||||
* Event-driven reconnect triggers: network `online`, window `focus`, and
|
||||
* visibility→visible each attempt an immediate `resumeReconnect()` when the
|
||||
* relay session is degraded (reconnecting/stalled), rate-limited by
|
||||
* `RESUME_TRIGGER_MIN_INTERVAL_MS`.
|
||||
*
|
||||
* Rationale (CMD+R gap audit G1): without these, recovery rides solely on
|
||||
* the backoff timer, which WKWebView throttles while the window is occluded
|
||||
* or the system was asleep. The moment a user focuses the window to hit
|
||||
* CMD+R *is* a focus event — this fires the reconnect first.
|
||||
*
|
||||
* Uses `resumeReconnect()`, NOT `preconnect()`: resume events bypass the
|
||||
* pending backoff timer but must preserve the terminal latch and the AUTH
|
||||
* rejection streak. Otherwise a focus/online event arriving during repeated
|
||||
* AUTH rejection would reset the consecutive-rejection cap and the session
|
||||
* could retry indefinitely instead of surfacing `disconnected`. The
|
||||
* terminal state stays user-owned via the reconnect card.
|
||||
*/
|
||||
export function useRelayResumeTriggers(): void {
|
||||
React.useEffect(() => {
|
||||
let lastAttemptAt = -Infinity;
|
||||
|
||||
const attempt = () => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!shouldTriggerResumeReconnect({
|
||||
connectionState: relayClient.getConnectionState(),
|
||||
lastAttemptAt,
|
||||
now,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastAttemptAt = now;
|
||||
// resumeReconnect() clears any pending backoff timer and connects now,
|
||||
// preserving terminal/AUTH-streak state. Failures re-arm the normal
|
||||
// backoff loop; nothing to handle here.
|
||||
void relayClient.resumeReconnect().catch(() => {});
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") attempt();
|
||||
};
|
||||
|
||||
window.addEventListener("online", attempt);
|
||||
window.addEventListener("focus", attempt);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("online", attempt);
|
||||
window.removeEventListener("focus", attempt);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user