Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/mobile/src/application-name.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import Constants from "expo-constants";
import { Platform } from "react-native";

export const applicationName = Constants.expoConfig?.name ?? "OpenCode2 Mobile";
export const applicationVersion = Constants.expoConfig?.version ?? "unknown";
export const applicationBuild =
Platform.OS === "ios"
? (Constants.expoConfig?.ios?.buildNumber ?? "unknown")
: String(Constants.expoConfig?.android?.versionCode ?? "unknown");
49 changes: 49 additions & 0 deletions apps/mobile/src/state/connection-runtime-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jest.mock("@opencode2-mobile/opencode-adapter", () => ({
mockClientCalls.set(options.baseUrl, count + 1);
return count % 2 === 0 ? pair.rest : pair.event;
},
openCodeClientContractVersion: "test-contract",
}));

jest.mock("../connections/connections-context", () => ({
Expand Down Expand Up @@ -102,6 +103,10 @@ test("aborts and ignores the old generation when switching connections", async (
version: "test",
});
expect(screen.getByText("connected")).toBeOnTheScreen();
expect(screen.getByTestId("runtime-diagnostics").props.children).toContain(
"generation_starts=1\ndurable_sequence_gaps=0\nsnapshot_requests=1\nsnapshots_installed=1",
);
expect(screen.getByTestId("runtime-diagnostics").props.children).toContain("generation=startup");

mockSelectedProfileId = "connection-1";
view.rerender(
Expand Down Expand Up @@ -160,6 +165,49 @@ test("aggregates event bursts without evicting transport status history", () =>
expect(diagnostics).toHaveLength(5);
});

test("bounds diagnostic kinds independently and formats redacted transport metadata", () => {
let diagnostics: RuntimeDiagnosticEntry[] = [
{ atMs: 1, kind: "event", value: "session.updated" },
];
for (let index = 0; index < 70; index += 1) {
diagnostics = appendDiagnostic(diagnostics, {
atMs: 300 + index,
kind: "event",
value: `event.${index}`,
});
diagnostics = appendDiagnostic(diagnostics, {
atMs: 100 + index,
kind: "status",
value: index % 2 === 0 ? "connecting" : "connected",
});
diagnostics = appendDiagnostic(diagnostics, {
atMs: 200 + index,
kind: "generation",
value: "durable_gap",
});
}

expect(diagnostics.filter((entry) => entry.kind === "event")).toHaveLength(64);
expect(diagnostics.filter((entry) => entry.kind === "status")).toHaveLength(64);
expect(diagnostics.filter((entry) => entry.kind === "generation")).toHaveLength(64);
expect(
formatDiagnostics("connected", diagnostics, {
appBuild: "7",
appVersion: "0.1.4",
clientContractVersion: "0.0.0-beta-18387",
metrics: {
durableSequenceGaps: 12,
generationStarts: 14,
snapshotRequests: 14,
snapshotsInstalled: 10,
},
serverVersion: "unsafe\nvalue",
}),
).toContain(
"app_version=0.1.4\napp_build=7\nclient_contract=0.0.0-beta-18387\nserver_version=unknown\ngeneration_starts=14\ndurable_sequence_gaps=12\nsnapshot_requests=14\nsnapshots_installed=10",
);
});

function RuntimeStatus() {
const runtime = useConnectionRuntime();
return (
Expand All @@ -168,6 +216,7 @@ function RuntimeStatus() {
<Text>
{runtime.connectionId}:{runtime.restClient ? "ready" : "none"}
</Text>
<Text testID="runtime-diagnostics">{runtime.getDiagnosticsText()}</Text>
</>
);
}
Expand Down
81 changes: 75 additions & 6 deletions apps/mobile/src/state/connection-runtime-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type LocationRef,
type OpenCodeClient,
type OpenCodeEvent,
openCodeClientContractVersion,
} from "@opencode2-mobile/opencode-adapter";
import { focusManager, onlineManager, useQueryClient } from "@tanstack/react-query";
import * as Network from "expo-network";
Expand All @@ -17,7 +18,7 @@ import {
} from "react";
import { AppState } from "react-native";

import { applicationName } from "../application-name";
import { applicationBuild, applicationName, applicationVersion } from "../application-name";
import { connectionAuthorizationHeader } from "../connections/connection-authorization";
import { useConnections } from "../connections/connections-context";
import { boundedOpenCodeFetch, expoOpenCodeFetch } from "../expo-open-code-fetch";
Expand Down Expand Up @@ -77,6 +78,7 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode })
}>();
const statusRef = useRef<ConnectionTransportStatus>("idle");
const diagnosticsRef = useRef<RuntimeDiagnosticEntry[]>([]);
const transportMetricsRef = useRef<TransportDiagnosticMetrics>(emptyTransportDiagnosticMetrics());
const selectedRevisionRef = useRef<{ id: string; updatedAtMs: number } | undefined>(undefined);
const selected = connections.profiles.find(
(profile) => profile.id === connections.selectedProfileId,
Expand All @@ -92,6 +94,7 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode })
selectedRevisionRef.current = undefined;
statusRef.current = "idle";
diagnosticsRef.current = [];
transportMetricsRef.current = emptyTransportDiagnosticMetrics();
setStatus("idle");
setReconnectAttempt(0);
setCacheMetadata(undefined);
Expand All @@ -115,6 +118,7 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode })
}
statusRef.current = "connecting";
diagnosticsRef.current = [];
transportMetricsRef.current = emptyTransportDiagnosticMetrics();
resetTranscriptPerformanceMetrics();
setStatus("connecting");
setReconnectAttempt(0);
Expand Down Expand Up @@ -166,7 +170,20 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode })
value: redactedEventType(event),
});
},
onDurableGap() {
transportMetricsRef.current.durableSequenceGaps += 1;
},
onGeneration(reason) {
transportMetricsRef.current.generationStarts += 1;
transportMetricsRef.current.snapshotRequests += 1;
diagnosticsRef.current = appendDiagnostic(diagnosticsRef.current, {
atMs: Date.now(),
kind: "generation",
value: reason,
});
},
onSnapshot(snapshot) {
transportMetricsRef.current.snapshotsInstalled += 1;
setServerVersion(snapshot.health.version);
queryClient.setQueryData(openCodeQueryKeys.health(selected.id), snapshot.health);
queryClient.setQueryData(openCodeQueryKeys.projects(selected.id), snapshot.projects);
Expand Down Expand Up @@ -255,7 +272,13 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode })
eventLocations,
getDiagnosticsText: () =>
[
formatDiagnostics(statusRef.current, diagnosticsRef.current),
formatDiagnostics(statusRef.current, diagnosticsRef.current, {
appBuild: applicationBuild,
appVersion: applicationVersion,
clientContractVersion: openCodeClientContractVersion,
metrics: transportMetricsRef.current,
...(serverVersion ? { serverVersion } : {}),
}),
formatTranscriptPerformanceDiagnostics(getTranscriptPerformanceMetrics()),
].join("\n\n"),
includeAttentionLocation,
Expand Down Expand Up @@ -287,10 +310,27 @@ export type RuntimeDiagnosticEntry = {
atMs: number;
attempt?: number;
count?: number;
kind: "event" | "status";
kind: "event" | "generation" | "status";
value: string;
};

export type TransportDiagnosticMetrics = {
durableSequenceGaps: number;
generationStarts: number;
snapshotRequests: number;
snapshotsInstalled: number;
};

type RuntimeDiagnosticMetadata = {
appBuild: string;
appVersion: string;
clientContractVersion: string;
metrics: TransportDiagnosticMetrics;
serverVersion?: string;
};

const maxRuntimeDiagnosticsPerKind = 64;

export function appendDiagnostic(current: RuntimeDiagnosticEntry[], entry: RuntimeDiagnosticEntry) {
const next = [...current];
if (entry.kind === "event") {
Expand All @@ -313,18 +353,34 @@ export function appendDiagnostic(current: RuntimeDiagnosticEntry[], entry: Runti
next.push(entry);
}

while (next.length > 64) {
const oldestEventIndex = next.findIndex((currentEntry) => currentEntry.kind === "event");
next.splice(oldestEventIndex >= 0 ? oldestEventIndex : 0, 1);
while (
next.filter((currentEntry) => currentEntry.kind === entry.kind).length >
maxRuntimeDiagnosticsPerKind
) {
const oldestKindIndex = next.findIndex((currentEntry) => currentEntry.kind === entry.kind);
next.splice(oldestKindIndex, 1);
}
return next;
}

export function formatDiagnostics(
status: ConnectionTransportStatus,
diagnostics: RuntimeDiagnosticEntry[],
metadata?: RuntimeDiagnosticMetadata,
) {
const lines = [`${applicationName} redacted transport diagnostics`, `current_status=${status}`];
if (metadata) {
lines.push(
`app_version=${redactedDiagnosticValue(metadata.appVersion)}`,
`app_build=${redactedDiagnosticValue(metadata.appBuild)}`,
`client_contract=${redactedDiagnosticValue(metadata.clientContractVersion)}`,
`server_version=${redactedDiagnosticValue(metadata.serverVersion ?? "unknown")}`,
`generation_starts=${metadata.metrics.generationStarts}`,
`durable_sequence_gaps=${metadata.metrics.durableSequenceGaps}`,
`snapshot_requests=${metadata.metrics.snapshotRequests}`,
`snapshots_installed=${metadata.metrics.snapshotsInstalled}`,
);
}
for (const entry of diagnostics) {
lines.push(
`${formatDiagnosticTimestamp(entry.atMs)} ${entry.kind}=${entry.value}${
Expand All @@ -335,6 +391,19 @@ export function formatDiagnostics(
return lines.join("\n");
}

function emptyTransportDiagnosticMetrics(): TransportDiagnosticMetrics {
return {
durableSequenceGaps: 0,
generationStarts: 0,
snapshotRequests: 0,
snapshotsInstalled: 0,
};
}

function redactedDiagnosticValue(value: string) {
return /^[a-zA-Z0-9][a-zA-Z0-9.+_-]{0,127}$/.test(value) ? value : "unknown";
}

export function formatDiagnosticTimestamp(atMs: number) {
const date = new Date(atMs);
const offsetMinutes = -date.getTimezoneOffset();
Expand Down
34 changes: 34 additions & 0 deletions apps/mobile/src/state/connection-transport-coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { expect, jest, test } from "@jest/globals";
import type { OpenCodeClient, OpenCodeEvent } from "@opencode2-mobile/opencode-adapter";
import { eventRequiresConnectionSnapshot } from "./connection-event-query-bridge";
import {
type ConnectionGenerationReason,
ConnectionTransportCoordinator,
type ConnectionTransportCoordinatorOptions,
type ConnectionTransportStatus,
Expand Down Expand Up @@ -37,11 +38,15 @@ test("buffers events until authoritative snapshots are installed", async () => {
test("deduplicates event IDs and detects durable sequence gaps", async () => {
const stream = createEventStream();
const onEvent = jest.fn();
const onDurableGap = jest.fn();
const onSnapshot = jest.fn();
const onUncertain = jest.fn();
const generationReasons: ConnectionGenerationReason[] = [];
const coordinator = createCoordinator({
eventClient: { event: { subscribe: stream.subscribe } } as never,
onDurableGap,
onEvent,
onGeneration: (reason) => generationReasons.push(reason),
onSnapshot,
onUncertain,
restClient: createSnapshotClient(true).client,
Expand All @@ -57,16 +62,20 @@ test("deduplicates event IDs and detects durable sequence gaps", async () => {
await flush();

expect(onEvent).toHaveBeenCalledTimes(2);
expect(onDurableGap).toHaveBeenCalledTimes(1);
expect(onUncertain).toHaveBeenCalledTimes(1);
expect(onSnapshot).toHaveBeenCalledTimes(2);
expect(generationReasons).toEqual(["startup", "durable_gap"]);
});

test("reconnects with bounded full-jitter backoff", async () => {
const stream = createEventStream();
const scheduled: Array<{ callback: () => void; delay: number }> = [];
const statuses: ConnectionTransportStatus[] = [];
const generationReasons: ConnectionGenerationReason[] = [];
const coordinator = createCoordinator({
eventClient: { event: { subscribe: stream.subscribe } } as never,
onGeneration: (reason) => generationReasons.push(reason),
onStatus: (status) => statuses.push(status),
random: () => 0.5,
restClient: createSnapshotClient(true).client,
Expand All @@ -86,6 +95,7 @@ test("reconnects with bounded full-jitter backoff", async () => {

scheduled[0]?.callback();
expect(stream.generations).toBe(2);
expect(generationReasons).toEqual(["startup", "retry"]);
});

test("bounds the pre-snapshot event buffer and marks state uncertain", async () => {
Expand Down Expand Up @@ -167,8 +177,10 @@ test("reconciles coordinator-owned roots for an uncertain event type", async ()
const stream = createEventStream();
const onSnapshot = jest.fn();
const onUncertain = jest.fn();
const generationReasons: ConnectionGenerationReason[] = [];
const coordinator = createCoordinator({
eventClient: { event: { subscribe: stream.subscribe } } as never,
onGeneration: (reason) => generationReasons.push(reason),
onSnapshot,
onUncertain,
restClient: createSnapshotClient(true).client,
Expand All @@ -183,6 +195,7 @@ test("reconciles coordinator-owned roots for an uncertain event type", async ()

expect(onUncertain).toHaveBeenCalledTimes(1);
expect(onSnapshot).toHaveBeenCalledTimes(2);
expect(generationReasons).toEqual(["startup", "event_reconciliation"]);
});

test("keeps a healthy generation live for installation advisory events", async () => {
Expand Down Expand Up @@ -290,8 +303,10 @@ test("reconciles a replacement snapshot after the stream restarts", async () =>
test("stops streams while backgrounded or offline", async () => {
const stream = createEventStream();
const statuses: ConnectionTransportStatus[] = [];
const generationReasons: ConnectionGenerationReason[] = [];
const coordinator = createCoordinator({
eventClient: { event: { subscribe: stream.subscribe } } as never,
onGeneration: (reason) => generationReasons.push(reason),
onStatus: (status) => statuses.push(status),
restClient: createSnapshotClient(true).client,
});
Expand All @@ -310,14 +325,33 @@ test("stops streams while backgrounded or offline", async () => {

coordinator.setOnline(true);
expect(stream.generations).toBe(3);
expect(generationReasons).toEqual(["startup", "foreground", "network_restored"]);
coordinator.stop();
});

test("records explicit reconciliation as a generation reason", async () => {
const generationReasons: ConnectionGenerationReason[] = [];
const coordinator = createCoordinator({
eventClient: { event: { subscribe: createEventStream().subscribe } } as never,
onGeneration: (reason) => generationReasons.push(reason),
restClient: createSnapshotClient(true).client,
});

coordinator.start();
await flush();
coordinator.reconcile();

expect(generationReasons).toEqual(["startup", "manual_reconcile"]);
coordinator.stop();
});

function createCoordinator(overrides: Partial<ConnectionTransportCoordinatorOptions> = {}) {
return new ConnectionTransportCoordinator({
eventClient: overrides.eventClient ?? ({} as never),
...(overrides.maxBufferedEvents ? { maxBufferedEvents: overrides.maxBufferedEvents } : {}),
...(overrides.onDurableGap ? { onDurableGap: overrides.onDurableGap } : {}),
onEvent: overrides.onEvent ?? (() => undefined),
...(overrides.onGeneration ? { onGeneration: overrides.onGeneration } : {}),
onSnapshot: overrides.onSnapshot ?? (() => undefined),
onStatus: overrides.onStatus ?? (() => undefined),
onUncertain: overrides.onUncertain ?? (() => undefined),
Expand Down
Loading