feat(telemetry): instrument renderer failure and CTA events (#2360)
This commit is contained in:
parent
860fa5a242
commit
0f2295c0c0
|
|
@ -478,6 +478,7 @@ async function refreshDaemonStatus(): Promise<DaemonStatus> {
|
||||||
setDaemonStatus({
|
setDaemonStatus({
|
||||||
state: "stopped",
|
state: "stopped",
|
||||||
message: "AO daemon is no longer reachable.",
|
message: "AO daemon is no longer reachable.",
|
||||||
|
code: "daemon_unreachable",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return daemonStatus;
|
return daemonStatus;
|
||||||
|
|
@ -518,6 +519,7 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
|
||||||
setDaemonStatus({
|
setDaemonStatus({
|
||||||
state: "stopped",
|
state: "stopped",
|
||||||
message: "AO_DAEMON_COMMAND is not configured; renderer uses loopback REST when available.",
|
message: "AO_DAEMON_COMMAND is not configured; renderer uses loopback REST when available.",
|
||||||
|
code: "not_configured",
|
||||||
});
|
});
|
||||||
return daemonStatus;
|
return daemonStatus;
|
||||||
}
|
}
|
||||||
|
|
@ -640,6 +642,7 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
|
||||||
setDaemonStatus({
|
setDaemonStatus({
|
||||||
state: "error",
|
state: "error",
|
||||||
message: `Bundled AO daemon binary was not found at ${launch.command}. Rebuild the desktop package.`,
|
message: `Bundled AO daemon binary was not found at ${launch.command}. Rebuild the desktop package.`,
|
||||||
|
code: "binary_missing",
|
||||||
});
|
});
|
||||||
return daemonStatus;
|
return daemonStatus;
|
||||||
}
|
}
|
||||||
|
|
@ -737,6 +740,7 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
|
||||||
state: "ready",
|
state: "ready",
|
||||||
port: process.env.AO_PORT ? Number(process.env.AO_PORT) : undefined,
|
port: process.env.AO_PORT ? Number(process.env.AO_PORT) : undefined,
|
||||||
message: "Daemon port not confirmed from logs or running.json; assuming the configured port.",
|
message: "Daemon port not confirmed from logs or running.json; assuming the configured port.",
|
||||||
|
code: "port_unconfirmed",
|
||||||
});
|
});
|
||||||
}, PORT_DISCOVERY_TIMEOUT_MS);
|
}, PORT_DISCOVERY_TIMEOUT_MS);
|
||||||
|
|
||||||
|
|
@ -745,17 +749,29 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
|
||||||
if (daemonProcess !== child) return;
|
if (daemonProcess !== child) return;
|
||||||
daemonProcess = null;
|
daemonProcess = null;
|
||||||
if (daemonStoppingProcess === child) daemonStoppingProcess = null;
|
if (daemonStoppingProcess === child) daemonStoppingProcess = null;
|
||||||
setDaemonStatus({ state: "error", message: error.message });
|
setDaemonStatus({ state: "error", message: error.message, code: "spawn_failed" });
|
||||||
});
|
});
|
||||||
|
|
||||||
child.once("exit", (code, signal) => {
|
child.once("exit", (code, signal) => {
|
||||||
stopDiscovery();
|
stopDiscovery();
|
||||||
if (daemonProcess !== child) return;
|
if (daemonProcess !== child) return;
|
||||||
daemonProcess = null;
|
daemonProcess = null;
|
||||||
if (daemonStoppingProcess === child) daemonStoppingProcess = null;
|
// An explicit stopDaemon() already set a clean `{ state: "stopped" }`.
|
||||||
|
// daemon-telemetry reports any status carrying a `code` as
|
||||||
|
// ao.renderer.daemon_failure, so don't stamp `code: "exited"` on a stop
|
||||||
|
// the user or app asked for — that would count intentional stops as
|
||||||
|
// failures. Preserve the clean stopped status instead.
|
||||||
|
if (daemonStoppingProcess === child) {
|
||||||
|
daemonStoppingProcess = null;
|
||||||
|
setDaemonStatus({ state: "stopped" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setDaemonStatus({
|
setDaemonStatus({
|
||||||
state: "stopped",
|
state: "stopped",
|
||||||
message: signal ? `Daemon exited with ${signal}` : `Daemon exited with code ${code ?? "unknown"}`,
|
message: signal ? `Daemon exited with ${signal}` : `Daemon exited with code ${code ?? "unknown"}`,
|
||||||
|
code: "exited",
|
||||||
|
exitCode: code,
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Input } from "./ui/input";
|
||||||
import { RequiredAgentField } from "./CreateProjectAgentSheet";
|
import { RequiredAgentField } from "./CreateProjectAgentSheet";
|
||||||
import type { components } from "../../api/schema";
|
import type { components } from "../../api/schema";
|
||||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||||
|
import { captureRendererEvent } from "../lib/telemetry";
|
||||||
import type { AgentProvider } from "../types/workspace";
|
import type { AgentProvider } from "../types/workspace";
|
||||||
import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery";
|
import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery";
|
||||||
|
|
||||||
|
|
@ -88,6 +89,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
void captureRendererEvent("ao.renderer.task_create_requested", { project_id: projectId });
|
||||||
try {
|
try {
|
||||||
const { data, error: apiError } = await apiClient.POST("/api/v1/sessions", {
|
const { data, error: apiError } = await apiClient.POST("/api/v1/sessions", {
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -101,9 +103,11 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
|
||||||
});
|
});
|
||||||
if (apiError) throw new Error(apiErrorMessage(apiError, "Unable to start task"));
|
if (apiError) throw new Error(apiErrorMessage(apiError, "Unable to start task"));
|
||||||
if (!data?.session?.id) throw new Error("Task creation returned no session");
|
if (!data?.session?.id) throw new Error("Task creation returned no session");
|
||||||
|
void captureRendererEvent("ao.renderer.task_create_succeeded", { project_id: projectId });
|
||||||
onCreated(data.session.id);
|
onCreated(data.session.id);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
void captureRendererEvent("ao.renderer.task_create_failed", { project_id: projectId });
|
||||||
void queryClient.invalidateQueries({ queryKey: agentsQueryKey });
|
void queryClient.invalidateQueries({ queryKey: agentsQueryKey });
|
||||||
setError(err instanceof Error ? err.message : "Unable to start task");
|
setError(err instanceof Error ? err.message : "Unable to start task");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import { aoBridge } from "../lib/bridge";
|
import { aoBridge } from "../lib/bridge";
|
||||||
import { formatTimeCompact } from "../lib/format-time";
|
import { formatTimeCompact } from "../lib/format-time";
|
||||||
import { createNotificationsTransport, type NotificationDTO, unreadNotificationsQueryKey } from "../lib/notifications";
|
import { createNotificationsTransport, type NotificationDTO, unreadNotificationsQueryKey } from "../lib/notifications";
|
||||||
|
import { captureRendererEvent } from "../lib/telemetry";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -37,11 +38,13 @@ export function NotificationCenter({ style }: NotificationCenterProps) {
|
||||||
(notification: NotificationDTO) => {
|
(notification: NotificationDTO) => {
|
||||||
const target = notification.target;
|
const target = notification.target;
|
||||||
if (target.kind === "pr" && target.prUrl) {
|
if (target.kind === "pr" && target.prUrl) {
|
||||||
|
void captureRendererEvent("ao.renderer.notification_opened", { target: "pr" });
|
||||||
window.open(target.prUrl, "_blank", "noopener,noreferrer");
|
window.open(target.prUrl, "_blank", "noopener,noreferrer");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sessionId = target.sessionId || notification.sessionId;
|
const sessionId = target.sessionId || notification.sessionId;
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
void captureRendererEvent("ao.renderer.notification_opened", { target: "session" });
|
||||||
if (notification.projectId) {
|
if (notification.projectId) {
|
||||||
void navigate({
|
void navigate({
|
||||||
to: "/projects/$projectId/sessions/$sessionId",
|
to: "/projects/$projectId/sessions/$sessionId",
|
||||||
|
|
@ -66,6 +69,7 @@ export function NotificationCenter({ style }: NotificationCenterProps) {
|
||||||
|
|
||||||
const markOneRead = async (id: string) => {
|
const markOneRead = async (id: string) => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
|
void captureRendererEvent("ao.renderer.notification_marked_read", { scope: "single" });
|
||||||
try {
|
try {
|
||||||
await markRead.mutateAsync(id);
|
await markRead.mutateAsync(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -75,6 +79,7 @@ export function NotificationCenter({ style }: NotificationCenterProps) {
|
||||||
|
|
||||||
const markAll = async () => {
|
const markAll = async () => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
|
void captureRendererEvent("ao.renderer.notification_marked_read", { scope: "all" });
|
||||||
try {
|
try {
|
||||||
await markAllRead.mutateAsync();
|
await markAllRead.mutateAsync();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { components } from "../../api/schema";
|
||||||
import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery";
|
import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery";
|
||||||
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
||||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||||
|
import { captureRendererEvent } from "../lib/telemetry";
|
||||||
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
|
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
|
||||||
import { newestActiveOrchestrator } from "../types/workspace";
|
import { newestActiveOrchestrator } from "../types/workspace";
|
||||||
import { RequiredAgentField } from "./CreateProjectAgentSheet";
|
import { RequiredAgentField } from "./CreateProjectAgentSheet";
|
||||||
|
|
@ -120,6 +121,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
void captureRendererEvent("ao.renderer.settings_save_requested", { project_id: projectId });
|
||||||
// PUT replaces the whole config; merge the edited fields over what loaded
|
// PUT replaces the whole config; merge the edited fields over what loaded
|
||||||
// so we don't drop env/symlinks/postCreate the form doesn't expose.
|
// so we don't drop env/symlinks/postCreate the form doesn't expose.
|
||||||
const next: ProjectConfig = {
|
const next: ProjectConfig = {
|
||||||
|
|
@ -146,7 +148,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
||||||
(activeOrchestrator && activeOrchestrator.provider !== form.orchestratorAgent)
|
(activeOrchestrator && activeOrchestrator.provider !== form.orchestratorAgent)
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
await spawnOrchestrator(projectId, true);
|
await spawnOrchestrator(projectId, "settings", true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
replacementError: error instanceof Error ? error.message : "Could not replace orchestrator",
|
replacementError: error instanceof Error ? error.message : "Could not replace orchestrator",
|
||||||
|
|
@ -156,12 +158,16 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
||||||
return { replacementError: null };
|
return { replacementError: null };
|
||||||
},
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
|
void captureRendererEvent("ao.renderer.settings_save_succeeded", { project_id: projectId });
|
||||||
setSavedAt(Date.now());
|
setSavedAt(Date.now());
|
||||||
setReplacementError(result.replacementError);
|
setReplacementError(result.replacementError);
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
|
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
|
||||||
onSaved();
|
onSaved();
|
||||||
},
|
},
|
||||||
|
onError: () => {
|
||||||
|
void captureRendererEvent("ao.renderer.settings_save_failed", { project_id: projectId });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export function RestoreUnavailableDialog({ open, session, onOpenChange, onRecrea
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
try {
|
try {
|
||||||
const id = await spawnOrchestrator(session.workspaceId, true);
|
const id = await spawnOrchestrator(session.workspaceId, "restore_dialog", true);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onRecreated(id);
|
onRecreated(id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
|
||||||
}
|
}
|
||||||
setIsSpawning(true);
|
setIsSpawning(true);
|
||||||
try {
|
try {
|
||||||
const sessionId = await spawnOrchestrator(projectId);
|
const sessionId = await spawnOrchestrator(projectId, "board");
|
||||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||||
void navigate({
|
void navigate({
|
||||||
to: "/projects/$projectId/sessions/$sessionId",
|
to: "/projects/$projectId/sessions/$sessionId",
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ export function ShellTopbar() {
|
||||||
}
|
}
|
||||||
setIsSpawning(true);
|
setIsSpawning(true);
|
||||||
try {
|
try {
|
||||||
const sessionId = await spawnOrchestrator(projectId);
|
const sessionId = await spawnOrchestrator(projectId, "topbar");
|
||||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||||
void navigate({
|
void navigate({
|
||||||
to: "/projects/$projectId/sessions/$sessionId",
|
to: "/projects/$projectId/sessions/$sessionId",
|
||||||
|
|
@ -252,16 +252,21 @@ export function TopbarKillButton({ session }: { session: WorkspaceSession }) {
|
||||||
|
|
||||||
const kill = useMutation({
|
const kill = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
void captureRendererEvent("ao.renderer.session_kill_requested", { project_id: session.workspaceId });
|
||||||
const { error: apiError } = await apiClient.POST("/api/v1/sessions/{sessionId}/kill", {
|
const { error: apiError } = await apiClient.POST("/api/v1/sessions/{sessionId}/kill", {
|
||||||
params: { path: { sessionId: session.id } },
|
params: { path: { sessionId: session.id } },
|
||||||
});
|
});
|
||||||
if (apiError) throw new Error(apiErrorMessage(apiError));
|
if (apiError) throw new Error(apiErrorMessage(apiError));
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
void captureRendererEvent("ao.renderer.session_kill_succeeded", { project_id: session.workspaceId });
|
||||||
setConfirming(false);
|
setConfirming(false);
|
||||||
void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||||
},
|
},
|
||||||
onError: (e) => setError(e instanceof Error ? e.message : "Kill failed"),
|
onError: (e) => {
|
||||||
|
void captureRendererEvent("ao.renderer.session_kill_failed", { project_id: session.workspaceId });
|
||||||
|
setError(e instanceof Error ? e.message : "Kill failed");
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (confirming) {
|
if (confirming) {
|
||||||
|
|
|
||||||
|
|
@ -445,7 +445,7 @@ function ProjectItem({
|
||||||
}
|
}
|
||||||
setIsSpawning(true);
|
setIsSpawning(true);
|
||||||
try {
|
try {
|
||||||
const sessionId = await spawnOrchestrator(workspace.id);
|
const sessionId = await spawnOrchestrator(workspace.id, "sidebar");
|
||||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||||
selection.goSession(workspace.id, sessionId);
|
selection.goSession(workspace.id, sessionId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getApiBaseUrl } from "../lib/api-client";
|
import { getApiBaseUrl } from "../lib/api-client";
|
||||||
|
import { captureRendererEvent } from "../lib/telemetry";
|
||||||
import { createTerminalMux, muxUrlFromApiBase, type TerminalMux } from "../lib/terminal-mux";
|
import { createTerminalMux, muxUrlFromApiBase, type TerminalMux } from "../lib/terminal-mux";
|
||||||
import type { WorkspaceSession } from "../types/workspace";
|
import type { WorkspaceSession } from "../types/workspace";
|
||||||
import { workspaceQueryKey } from "./useWorkspaceQuery";
|
import { workspaceQueryKey } from "./useWorkspaceQuery";
|
||||||
|
|
@ -213,6 +214,7 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
|
||||||
terminal.writeln(`\r\n\x1b[2m[terminal error] ${message}\x1b[0m`);
|
terminal.writeln(`\r\n\x1b[2m[terminal error] ${message}\x1b[0m`);
|
||||||
setError(message);
|
setError(message);
|
||||||
transition("error");
|
transition("error");
|
||||||
|
void captureRendererEvent("ao.renderer.terminal_attach_failed", { reason: "pane_error" });
|
||||||
invalidateWorkspaces();
|
invalidateWorkspaces();
|
||||||
}),
|
}),
|
||||||
mux.onConnectionChange((connectionState) => {
|
mux.onConnectionChange((connectionState) => {
|
||||||
|
|
@ -268,6 +270,11 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
|
||||||
r.openTimer = setTimeout(() => {
|
r.openTimer = setTimeout(() => {
|
||||||
if (!isCurrentAttachment(generation, handle, mux)) return;
|
if (!isCurrentAttachment(generation, handle, mux)) return;
|
||||||
r.openTimer = null;
|
r.openTimer = null;
|
||||||
|
// Only the first timeout of a reattach sequence is reported; the
|
||||||
|
// backoff loop retrying against a restarting daemon is not news.
|
||||||
|
if (r.attempts === 0) {
|
||||||
|
void captureRendererEvent("ao.renderer.terminal_attach_failed", { reason: "open_timeout" });
|
||||||
|
}
|
||||||
transition("reattaching");
|
transition("reattaching");
|
||||||
teardownMux();
|
teardownMux();
|
||||||
scheduleReattach();
|
scheduleReattach();
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
apiClient,
|
apiClient,
|
||||||
apiErrorMessage,
|
apiErrorMessage,
|
||||||
getApiBaseUrl,
|
getApiBaseUrl,
|
||||||
hasTrustedApiBaseUrl,
|
hasTrustedApiBaseUrl,
|
||||||
|
normalizeApiOperation,
|
||||||
setApiBaseUrl,
|
setApiBaseUrl,
|
||||||
subscribeApiBaseUrl,
|
subscribeApiBaseUrl,
|
||||||
} from "./api-client";
|
} from "./api-client";
|
||||||
|
import { captureRendererEvent } from "./telemetry";
|
||||||
|
|
||||||
|
vi.mock("./telemetry", () => ({
|
||||||
|
captureRendererEvent: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const captureMock = vi.mocked(captureRendererEvent);
|
||||||
|
|
||||||
describe("apiClient runtime base URL", () => {
|
describe("apiClient runtime base URL", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -162,6 +170,123 @@ describe("subscribeApiBaseUrl", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("normalizeApiOperation", () => {
|
||||||
|
it("replaces identifier segments after resource collections", () => {
|
||||||
|
expect(normalizeApiOperation("get", "/api/v1/projects/my project id")).toBe("GET /api/v1/projects/:id");
|
||||||
|
expect(normalizeApiOperation("POST", "/api/v1/sessions/ao-42/kill")).toBe("POST /api/v1/sessions/:id/kill");
|
||||||
|
expect(normalizeApiOperation("PUT", "/api/v1/projects/p1/config")).toBe("PUT /api/v1/projects/:id/config");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves collection and non-resource paths untouched", () => {
|
||||||
|
expect(normalizeApiOperation("GET", "/api/v1/projects")).toBe("GET /api/v1/projects");
|
||||||
|
expect(normalizeApiOperation("POST", "/api/v1/orchestrators")).toBe("POST /api/v1/orchestrators");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps static child routes instead of treating them as ids", () => {
|
||||||
|
// These match an exact OpenAPI template, so the trailing segment must not
|
||||||
|
// be collapsed to :id (which would break aggregation and hide the route).
|
||||||
|
expect(normalizeApiOperation("POST", "/api/v1/notifications/read-all")).toBe("POST /api/v1/notifications/read-all");
|
||||||
|
expect(normalizeApiOperation("POST", "/api/v1/sessions/cleanup")).toBe("POST /api/v1/sessions/cleanup");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes ids for resources a collection heuristic would miss", () => {
|
||||||
|
expect(normalizeApiOperation("GET", "/api/v1/orchestrators/orch-abc")).toBe("GET /api/v1/orchestrators/:id");
|
||||||
|
expect(normalizeApiOperation("POST", "/api/v1/prs/pr-1/merge")).toBe("POST /api/v1/prs/:id/merge");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("api error telemetry", () => {
|
||||||
|
// The dedupe window keys off Date.now(); jump the clock far past any
|
||||||
|
// earlier test's reports so each test starts with a clean window.
|
||||||
|
let clock = Date.UTC(2100, 0, 1);
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
clock += 10 * 60_000;
|
||||||
|
vi.setSystemTime(clock);
|
||||||
|
captureMock.mockClear();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
setApiBaseUrl("http://127.0.0.1:3001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports http_5xx with a normalized operation", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("oops", { status: 500 }));
|
||||||
|
setApiBaseUrl("http://127.0.0.1:3037");
|
||||||
|
|
||||||
|
await apiClient.GET("/api/v1/projects");
|
||||||
|
|
||||||
|
expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
|
||||||
|
operation: "GET /api/v1/projects",
|
||||||
|
error_category: "http_5xx",
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports http_4xx with ids stripped from the operation", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("nope", { status: 404 }));
|
||||||
|
setApiBaseUrl("http://127.0.0.1:3037");
|
||||||
|
|
||||||
|
await apiClient.POST("/api/v1/sessions/{sessionId}/kill", {
|
||||||
|
params: { path: { sessionId: "ao-raw-id" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
|
||||||
|
operation: "POST /api/v1/sessions/:id/kill",
|
||||||
|
error_category: "http_4xx",
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports network_error and rethrows", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||||
|
setApiBaseUrl("http://127.0.0.1:3037");
|
||||||
|
|
||||||
|
await expect(apiClient.GET("/api/v1/projects")).rejects.toThrow("Failed to fetch");
|
||||||
|
|
||||||
|
expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
|
||||||
|
operation: "GET /api/v1/projects",
|
||||||
|
error_category: "network_error",
|
||||||
|
status: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not report caller-initiated aborts", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockRejectedValue(new DOMException("Aborted", "AbortError"));
|
||||||
|
setApiBaseUrl("http://127.0.0.1:3037");
|
||||||
|
|
||||||
|
await expect(apiClient.GET("/api/v1/projects")).rejects.toThrow("Aborted");
|
||||||
|
|
||||||
|
expect(captureMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports daemon_unavailable when the base URL is untrusted", async () => {
|
||||||
|
setApiBaseUrl(null);
|
||||||
|
|
||||||
|
await apiClient.GET("/api/v1/projects");
|
||||||
|
|
||||||
|
expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
|
||||||
|
operation: "GET /api/v1/projects",
|
||||||
|
error_category: "daemon_unavailable",
|
||||||
|
status: 503,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes repeated identical failures within the 30s window", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("oops", { status: 502 }));
|
||||||
|
setApiBaseUrl("http://127.0.0.1:3037");
|
||||||
|
|
||||||
|
await apiClient.GET("/api/v1/projects");
|
||||||
|
await apiClient.GET("/api/v1/projects");
|
||||||
|
expect(captureMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.setSystemTime(clock + 31_000);
|
||||||
|
await apiClient.GET("/api/v1/projects");
|
||||||
|
expect(captureMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("apiErrorMessage", () => {
|
describe("apiErrorMessage", () => {
|
||||||
it("preserves daemon error codes next to human messages", () => {
|
it("preserves daemon error codes next to human messages", () => {
|
||||||
expect(apiErrorMessage({ code: "AGENT_BINARY_NOT_FOUND", message: "agent binary not found on PATH" })).toBe(
|
expect(apiErrorMessage({ code: "AGENT_BINARY_NOT_FOUND", message: "agent binary not found on PATH" })).toBe(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import createClient from "openapi-fetch";
|
import createClient from "openapi-fetch";
|
||||||
import type { paths } from "../../api/schema";
|
import type { paths } from "../../api/schema";
|
||||||
|
import { captureRendererEvent } from "./telemetry";
|
||||||
|
|
||||||
function devApiBaseUrl(): string {
|
function devApiBaseUrl(): string {
|
||||||
return typeof window === "undefined" ? "http://127.0.0.1:3001" : window.location.origin;
|
return typeof window === "undefined" ? "http://127.0.0.1:3001" : window.location.origin;
|
||||||
|
|
@ -39,14 +40,134 @@ export function setApiBaseUrl(nextBaseUrl: string | null): void {
|
||||||
baseUrlListeners.forEach((listener) => listener());
|
baseUrlListeners.forEach((listener) => listener());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Route templates from the generated OpenAPI schema (frontend/src/api/schema.ts).
|
||||||
|
// Operation strings sent to telemetry must never contain raw IDs (project IDs
|
||||||
|
// are user-chosen strings), so we match each request path against these
|
||||||
|
// templates and report the template — collapsing `{param}` to `:id` — rather
|
||||||
|
// than guessing which segments are identifiers. Matching from the schema keeps
|
||||||
|
// static child routes (notifications/read-all, sessions/cleanup) intact and
|
||||||
|
// still normalizes IDs for every resource, including ones a segment heuristic
|
||||||
|
// would miss (orchestrators/{id}). Keep in sync with schema.ts.
|
||||||
|
const ROUTE_TEMPLATES = [
|
||||||
|
"/api/v1/events",
|
||||||
|
"/api/v1/import",
|
||||||
|
"/api/v1/notifications",
|
||||||
|
"/api/v1/notifications/{id}",
|
||||||
|
"/api/v1/notifications/read-all",
|
||||||
|
"/api/v1/notifications/stream",
|
||||||
|
"/api/v1/orchestrators",
|
||||||
|
"/api/v1/orchestrators/{id}",
|
||||||
|
"/api/v1/projects",
|
||||||
|
"/api/v1/projects/{id}",
|
||||||
|
"/api/v1/projects/{id}/config",
|
||||||
|
"/api/v1/prs/{id}/merge",
|
||||||
|
"/api/v1/prs/{id}/resolve-comments",
|
||||||
|
"/api/v1/sessions",
|
||||||
|
"/api/v1/sessions/{sessionId}",
|
||||||
|
"/api/v1/sessions/{sessionId}/activity",
|
||||||
|
"/api/v1/sessions/{sessionId}/kill",
|
||||||
|
"/api/v1/sessions/{sessionId}/pr",
|
||||||
|
"/api/v1/sessions/{sessionId}/pr/claim",
|
||||||
|
"/api/v1/sessions/{sessionId}/preview",
|
||||||
|
"/api/v1/sessions/{sessionId}/preview/files/*",
|
||||||
|
"/api/v1/sessions/{sessionId}/restore",
|
||||||
|
"/api/v1/sessions/{sessionId}/reviews",
|
||||||
|
"/api/v1/sessions/{sessionId}/reviews/submit",
|
||||||
|
"/api/v1/sessions/{sessionId}/reviews/trigger",
|
||||||
|
"/api/v1/sessions/{sessionId}/rollback",
|
||||||
|
"/api/v1/sessions/{sessionId}/send",
|
||||||
|
"/api/v1/sessions/cleanup",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Resource collections whose next path segment is an identifier. Only used as a
|
||||||
|
// defensive fallback for paths not covered by ROUTE_TEMPLATES; keeps IDs out of
|
||||||
|
// telemetry for known collections even if a route is ever missed above.
|
||||||
|
const RESOURCE_SEGMENTS = new Set(["projects", "sessions", "notifications", "workspaces", "prs", "orchestrators"]);
|
||||||
|
|
||||||
|
// Match a path against one template. `{param}` matches any single segment
|
||||||
|
// (reported as `:id`), a trailing `*` matches the remaining path, and every
|
||||||
|
// other segment must match literally. Returns the normalized template plus a
|
||||||
|
// score = number of literal segments matched, so the most specific template
|
||||||
|
// wins when several match (e.g. `read-all` beats `{id}`).
|
||||||
|
function matchRouteTemplate(pathname: string, template: string): { normalized: string; score: number } | null {
|
||||||
|
const pathSegs = pathname.split("/");
|
||||||
|
const tmplSegs = template.split("/");
|
||||||
|
const out: string[] = [];
|
||||||
|
let score = 0;
|
||||||
|
for (let i = 0; i < tmplSegs.length; i += 1) {
|
||||||
|
const t = tmplSegs[i];
|
||||||
|
if (t === "*") {
|
||||||
|
out.push("*");
|
||||||
|
return { normalized: out.join("/"), score };
|
||||||
|
}
|
||||||
|
const p = pathSegs[i];
|
||||||
|
if (p === undefined) return null;
|
||||||
|
if (t.startsWith("{") && t.endsWith("}")) {
|
||||||
|
out.push(":id");
|
||||||
|
} else if (t === p) {
|
||||||
|
out.push(t);
|
||||||
|
score += 1;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pathSegs.length !== tmplSegs.length) return null;
|
||||||
|
return { normalized: out.join("/"), score };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackNormalize(pathname: string): string {
|
||||||
|
const segments = pathname.split("/");
|
||||||
|
for (let i = 0; i < segments.length - 1; i += 1) {
|
||||||
|
if (RESOURCE_SEGMENTS.has(segments[i]) && segments[i + 1]) {
|
||||||
|
segments[i + 1] = ":id";
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segments.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeApiOperation(method: string, pathname: string): string {
|
||||||
|
let best: { normalized: string; score: number } | null = null;
|
||||||
|
for (const template of ROUTE_TEMPLATES) {
|
||||||
|
const match = matchRouteTemplate(pathname, template);
|
||||||
|
if (match && (best === null || match.score > best.score)) best = match;
|
||||||
|
}
|
||||||
|
return `${method.toUpperCase()} ${best?.normalized ?? fallbackNormalize(pathname)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiErrorCategory = "daemon_unavailable" | "network_error" | "http_4xx" | "http_5xx";
|
||||||
|
|
||||||
|
// One event per (operation, category, status) per window: a daemon outage
|
||||||
|
// makes every polling query fail at once and on every retry — the failure
|
||||||
|
// signal matters, the storm does not.
|
||||||
|
const API_ERROR_DEDUPE_MS = 30_000;
|
||||||
|
const lastApiErrorAt = new Map<string, number>();
|
||||||
|
|
||||||
|
function reportApiError(operation: string, category: ApiErrorCategory, status?: number): void {
|
||||||
|
const key = `${operation}|${category}|${status ?? ""}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const last = lastApiErrorAt.get(key);
|
||||||
|
if (last !== undefined && now - last < API_ERROR_DEDUPE_MS) return;
|
||||||
|
lastApiErrorAt.set(key, now);
|
||||||
|
void captureRendererEvent("ao.renderer.api_error", {
|
||||||
|
operation,
|
||||||
|
error_category: category,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function runtimeFetch(input: Request): Promise<Response> {
|
async function runtimeFetch(input: Request): Promise<Response> {
|
||||||
|
const operation = normalizeApiOperation(input.method, new URL(input.url).pathname);
|
||||||
const baseUrl = runtimeApiBaseUrl;
|
const baseUrl = runtimeApiBaseUrl;
|
||||||
if (baseUrl === null) {
|
if (baseUrl === null) {
|
||||||
|
reportApiError(operation, "daemon_unavailable", 503);
|
||||||
return new Response(JSON.stringify({ message: "AO daemon is not ready." }), {
|
return new Response(JSON.stringify({ message: "AO daemon is not ready." }), {
|
||||||
status: 503,
|
status: 503,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const send = async (): Promise<Response> => {
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
return fetch(input);
|
return fetch(input);
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +197,22 @@ async function runtimeFetch(input: Request): Promise<Response> {
|
||||||
integrity: input.integrity,
|
integrity: input.integrity,
|
||||||
keepalive: input.keepalive,
|
keepalive: input.keepalive,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await send();
|
||||||
|
} catch (error) {
|
||||||
|
// Caller-initiated aborts (unmounted components cancelling queries) are not failures.
|
||||||
|
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
||||||
|
reportApiError(operation, "network_error");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
reportApiError(operation, response.status >= 500 ? "http_5xx" : "http_4xx", response.status);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = createClient<paths>({
|
export const apiClient = createClient<paths>({
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { DaemonStatus } from "../../shared/daemon-status";
|
||||||
|
import { aoBridge } from "./bridge";
|
||||||
|
import { startDaemonFailureTelemetry } from "./daemon-telemetry";
|
||||||
|
import { captureRendererEvent } from "./telemetry";
|
||||||
|
|
||||||
|
vi.mock("./telemetry", () => ({
|
||||||
|
captureRendererEvent: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./bridge", () => ({
|
||||||
|
aoBridge: {
|
||||||
|
daemon: {
|
||||||
|
getStatus: vi.fn(),
|
||||||
|
onStatus: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const captureMock = vi.mocked(captureRendererEvent);
|
||||||
|
const getStatusMock = vi.mocked(aoBridge.daemon.getStatus);
|
||||||
|
const onStatusMock = vi.mocked(aoBridge.daemon.onStatus);
|
||||||
|
|
||||||
|
describe("daemon failure telemetry", () => {
|
||||||
|
let pushStatus!: (status: DaemonStatus) => void;
|
||||||
|
let stop: () => void = () => undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
captureMock.mockClear();
|
||||||
|
onStatusMock.mockClear();
|
||||||
|
getStatusMock.mockResolvedValue({ state: "starting" });
|
||||||
|
onStatusMock.mockImplementation((listener) => {
|
||||||
|
pushStatus = listener;
|
||||||
|
return () => undefined;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a failing status with coarse fields only", () => {
|
||||||
|
stop = startDaemonFailureTelemetry();
|
||||||
|
|
||||||
|
pushStatus({ state: "error", message: "spawn /Users/alice/ao failed", code: "spawn_failed" });
|
||||||
|
|
||||||
|
expect(captureMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(captureMock).toHaveBeenCalledWith("ao.renderer.daemon_failure", {
|
||||||
|
daemon_state: "error",
|
||||||
|
code: "spawn_failed",
|
||||||
|
exit_code: undefined,
|
||||||
|
signal: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes exit code and signal for daemon exits", () => {
|
||||||
|
stop = startDaemonFailureTelemetry();
|
||||||
|
|
||||||
|
pushStatus({ state: "stopped", code: "exited", exitCode: 1, signal: "SIGKILL" });
|
||||||
|
|
||||||
|
expect(captureMock).toHaveBeenCalledWith("ao.renderer.daemon_failure", {
|
||||||
|
daemon_state: "stopped",
|
||||||
|
code: "exited",
|
||||||
|
exit_code: 1,
|
||||||
|
signal: "SIGKILL",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores statuses without a failure code (healthy or user-initiated stop)", () => {
|
||||||
|
stop = startDaemonFailureTelemetry();
|
||||||
|
|
||||||
|
pushStatus({ state: "ready", port: 3037 });
|
||||||
|
pushStatus({ state: "stopped" });
|
||||||
|
|
||||||
|
expect(captureMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes identical consecutive failures and resets on recovery", () => {
|
||||||
|
stop = startDaemonFailureTelemetry();
|
||||||
|
|
||||||
|
pushStatus({ state: "error", code: "daemon_unreachable" });
|
||||||
|
pushStatus({ state: "error", code: "daemon_unreachable" });
|
||||||
|
expect(captureMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// A different failure is a new event.
|
||||||
|
pushStatus({ state: "stopped", code: "exited", exitCode: 137 });
|
||||||
|
expect(captureMock).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Recovery resets dedupe: the same failure recurring is reported again.
|
||||||
|
pushStatus({ state: "ready", port: 3037 });
|
||||||
|
pushStatus({ state: "error", code: "daemon_unreachable" });
|
||||||
|
expect(captureMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the initial status from getStatus on start", async () => {
|
||||||
|
getStatusMock.mockResolvedValue({ state: "error", code: "binary_missing" });
|
||||||
|
|
||||||
|
stop = startDaemonFailureTelemetry();
|
||||||
|
await vi.waitFor(() => expect(captureMock).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
expect(captureMock).toHaveBeenCalledWith("ao.renderer.daemon_failure", {
|
||||||
|
daemon_state: "error",
|
||||||
|
code: "binary_missing",
|
||||||
|
exit_code: undefined,
|
||||||
|
signal: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent while started and restartable after stop", () => {
|
||||||
|
stop = startDaemonFailureTelemetry();
|
||||||
|
const noop = startDaemonFailureTelemetry();
|
||||||
|
expect(onStatusMock).toHaveBeenCalledTimes(1);
|
||||||
|
noop();
|
||||||
|
|
||||||
|
stop();
|
||||||
|
stop = startDaemonFailureTelemetry();
|
||||||
|
expect(onStatusMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
// Daemon failures happen in the Electron main process, which has no PostHog
|
||||||
|
// client. Main stamps a machine-readable `code` on every failing DaemonStatus
|
||||||
|
// (shared/daemon-status.ts); this module rides the existing daemon:status IPC
|
||||||
|
// push and reports those failures through the renderer's telemetry client.
|
||||||
|
import type { DaemonStatus } from "../../shared/daemon-status";
|
||||||
|
import { aoBridge } from "./bridge";
|
||||||
|
import { captureRendererEvent } from "./telemetry";
|
||||||
|
|
||||||
|
let started = false;
|
||||||
|
let lastFailureKey: string | null = null;
|
||||||
|
|
||||||
|
function failureKey(status: DaemonStatus): string | null {
|
||||||
|
if (!status.code) return null;
|
||||||
|
return [status.state, status.code, status.exitCode ?? "", status.signal ?? ""].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportStatus(status: DaemonStatus): void {
|
||||||
|
const key = failureKey(status);
|
||||||
|
if (!key) {
|
||||||
|
// Healthy status resets dedupe so a repeat failure after recovery is a new event.
|
||||||
|
lastFailureKey = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === lastFailureKey) return;
|
||||||
|
lastFailureKey = key;
|
||||||
|
void captureRendererEvent("ao.renderer.daemon_failure", {
|
||||||
|
daemon_state: status.state,
|
||||||
|
code: status.code,
|
||||||
|
exit_code: typeof status.exitCode === "number" ? status.exitCode : undefined,
|
||||||
|
signal: status.signal ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent; returns a stop function (used by tests). */
|
||||||
|
export function startDaemonFailureTelemetry(): () => void {
|
||||||
|
if (started) return () => undefined;
|
||||||
|
started = true;
|
||||||
|
void aoBridge.daemon
|
||||||
|
.getStatus()
|
||||||
|
.then(reportStatus)
|
||||||
|
.catch(() => undefined);
|
||||||
|
let stopListener: () => void = () => undefined;
|
||||||
|
try {
|
||||||
|
stopListener = aoBridge.daemon.onStatus(reportStatus);
|
||||||
|
} catch {
|
||||||
|
// Preload bridge unavailable (browser preview): initial getStatus already handled.
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
started = false;
|
||||||
|
lastFailureKey = null;
|
||||||
|
stopListener();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ describe("restartProjectOrchestrator", () => {
|
||||||
onError,
|
onError,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(spawnMock).toHaveBeenCalledWith("proj-1", true);
|
expect(spawnMock).toHaveBeenCalledWith("proj-1", "restart", true);
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey });
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey });
|
||||||
expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(1, "proj-1", null);
|
expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(1, "proj-1", null);
|
||||||
expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(2, "proj-1", "missing goose binary");
|
expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(2, "proj-1", "missing goose binary");
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export async function restartProjectOrchestrator({
|
||||||
setProjectRestarting(projectId, true);
|
setProjectRestarting(projectId, true);
|
||||||
setOrchestratorReplacementError(projectId, null);
|
setOrchestratorReplacementError(projectId, null);
|
||||||
try {
|
try {
|
||||||
const sessionId = await spawnOrchestrator(projectId, true);
|
const sessionId = await spawnOrchestrator(projectId, "restart", true);
|
||||||
await refreshWorkspaceState(queryClient);
|
await refreshWorkspaceState(queryClient);
|
||||||
void navigate({
|
void navigate({
|
||||||
to: "/projects/$projectId/sessions/$sessionId",
|
to: "/projects/$projectId/sessions/$sessionId",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import { spawnOrchestrator } from "./spawn-orchestrator";
|
import { spawnOrchestrator } from "./spawn-orchestrator";
|
||||||
import { apiClient } from "./api-client";
|
import { apiClient } from "./api-client";
|
||||||
|
import { captureRendererEvent } from "./telemetry";
|
||||||
|
|
||||||
vi.mock("./api-client", () => ({
|
vi.mock("./api-client", () => ({
|
||||||
apiClient: { POST: vi.fn() },
|
apiClient: { POST: vi.fn() },
|
||||||
|
|
@ -14,6 +15,12 @@ vi.mock("./api-client", () => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./telemetry", () => ({
|
||||||
|
captureRendererEvent: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const captureMock = vi.mocked(captureRendererEvent);
|
||||||
|
|
||||||
describe("spawnOrchestrator", () => {
|
describe("spawnOrchestrator", () => {
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
|
@ -23,7 +30,7 @@ describe("spawnOrchestrator", () => {
|
||||||
error: undefined,
|
error: undefined,
|
||||||
response: { status: 201 },
|
response: { status: 201 },
|
||||||
});
|
});
|
||||||
const id = await spawnOrchestrator("proj", true);
|
const id = await spawnOrchestrator("proj", "restore_dialog", true);
|
||||||
expect(id).toBe("proj-9");
|
expect(id).toBe("proj-9");
|
||||||
expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", {
|
expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", {
|
||||||
body: { projectId: "proj", clean: true },
|
body: { projectId: "proj", clean: true },
|
||||||
|
|
@ -36,12 +43,43 @@ describe("spawnOrchestrator", () => {
|
||||||
error: undefined,
|
error: undefined,
|
||||||
response: { status: 201 },
|
response: { status: 201 },
|
||||||
});
|
});
|
||||||
await spawnOrchestrator("proj");
|
await spawnOrchestrator("proj", "board");
|
||||||
expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", {
|
expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", {
|
||||||
body: { projectId: "proj", clean: false },
|
body: { projectId: "proj", clean: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emits the requested + succeeded triad keyed by source", async () => {
|
||||||
|
(apiClient.POST as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
data: { orchestrator: { id: "proj-7" } },
|
||||||
|
error: undefined,
|
||||||
|
response: { status: 201 },
|
||||||
|
});
|
||||||
|
await spawnOrchestrator("proj", "sidebar");
|
||||||
|
expect(captureMock).toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_requested", {
|
||||||
|
project_id: "proj",
|
||||||
|
source: "sidebar",
|
||||||
|
});
|
||||||
|
expect(captureMock).toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_succeeded", {
|
||||||
|
project_id: "proj",
|
||||||
|
source: "sidebar",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the failed event and rethrows when the daemon rejects the spawn", async () => {
|
||||||
|
(apiClient.POST as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
data: undefined,
|
||||||
|
error: { message: "boom" },
|
||||||
|
response: { status: 500 },
|
||||||
|
});
|
||||||
|
await expect(spawnOrchestrator("proj", "topbar")).rejects.toThrow("boom");
|
||||||
|
expect(captureMock).toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_failed", {
|
||||||
|
project_id: "proj",
|
||||||
|
source: "topbar",
|
||||||
|
});
|
||||||
|
expect(captureMock).not.toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_succeeded", expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
it("surfaces daemon spawn error messages and codes", async () => {
|
it("surfaces daemon spawn error messages and codes", async () => {
|
||||||
(apiClient.POST as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(apiClient.POST as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
|
|
@ -49,6 +87,8 @@ describe("spawnOrchestrator", () => {
|
||||||
response: { status: 400 },
|
response: { status: 400 },
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(spawnOrchestrator("proj")).rejects.toThrow("agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)");
|
await expect(spawnOrchestrator("proj", "board")).rejects.toThrow(
|
||||||
|
"agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,31 @@
|
||||||
import { apiClient, apiErrorMessage } from "./api-client";
|
import { apiClient, apiErrorMessage } from "./api-client";
|
||||||
|
import { captureRendererEvent } from "./telemetry";
|
||||||
|
|
||||||
|
// Every UI entry point that spawns an orchestrator: the board CTA, the topbar
|
||||||
|
// and sidebar launchers, the restore-unavailable dialog, and the auto-spawn
|
||||||
|
// right after a project is added. Emitting the triad from inside
|
||||||
|
// spawnOrchestrator (keyed by source) guarantees each path reports, instead of
|
||||||
|
// each call site remembering to instrument itself. Keep in sync with the
|
||||||
|
// allowed-source list in telemetry.ts.
|
||||||
|
export type OrchestratorSpawnSource =
|
||||||
|
| "board"
|
||||||
|
| "restore_dialog"
|
||||||
|
| "topbar"
|
||||||
|
| "sidebar"
|
||||||
|
| "project_add"
|
||||||
|
| "settings"
|
||||||
|
| "restart";
|
||||||
|
|
||||||
/** Spawn the project's orchestrator session via the daemon API. When clean is
|
/** Spawn the project's orchestrator session via the daemon API. When clean is
|
||||||
* true the daemon first tears down any active orchestrator for the project, then
|
* true the daemon first tears down any active orchestrator for the project, then
|
||||||
* re-spawns one on the canonical branch (reattaching the existing branch). */
|
* re-spawns one on the canonical branch (reattaching the existing branch). */
|
||||||
export async function spawnOrchestrator(projectId: string, clean = false): Promise<string> {
|
export async function spawnOrchestrator(
|
||||||
|
projectId: string,
|
||||||
|
source: OrchestratorSpawnSource,
|
||||||
|
clean = false,
|
||||||
|
): Promise<string> {
|
||||||
|
void captureRendererEvent("ao.renderer.orchestrator_spawn_requested", { project_id: projectId, source });
|
||||||
|
try {
|
||||||
const { data, error, response } = await apiClient.POST("/api/v1/orchestrators", {
|
const { data, error, response } = await apiClient.POST("/api/v1/orchestrators", {
|
||||||
body: { projectId, clean },
|
body: { projectId, clean },
|
||||||
});
|
});
|
||||||
|
|
@ -15,5 +37,10 @@ export async function spawnOrchestrator(projectId: string, clean = false): Promi
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void captureRendererEvent("ao.renderer.orchestrator_spawn_succeeded", { project_id: projectId, source });
|
||||||
return data.orchestrator.id;
|
return data.orchestrator.id;
|
||||||
|
} catch (err) {
|
||||||
|
void captureRendererEvent("ao.renderer.orchestrator_spawn_failed", { project_id: projectId, source });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,4 +111,95 @@ describe("telemetry sanitizers", () => {
|
||||||
"https://api.example.com/endpoint",
|
"https://api.example.com/endpoint",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hashes project ids and drops everything else on CTA triads", async () => {
|
||||||
|
const triads = [
|
||||||
|
"ao.renderer.task_create_requested",
|
||||||
|
"ao.renderer.task_create_succeeded",
|
||||||
|
"ao.renderer.task_create_failed",
|
||||||
|
"ao.renderer.session_kill_requested",
|
||||||
|
"ao.renderer.session_kill_succeeded",
|
||||||
|
"ao.renderer.session_kill_failed",
|
||||||
|
"ao.renderer.settings_save_requested",
|
||||||
|
"ao.renderer.settings_save_succeeded",
|
||||||
|
"ao.renderer.settings_save_failed",
|
||||||
|
];
|
||||||
|
for (const event of triads) {
|
||||||
|
const props = await sanitizeRendererProperties(event, {
|
||||||
|
project_id: "demo-project",
|
||||||
|
title: "raw user text",
|
||||||
|
error: "raw error with /Users/alice/path",
|
||||||
|
});
|
||||||
|
expect(Object.keys(props)).toEqual(["project_id_hash"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only the source enum on orchestrator_spawn events", async () => {
|
||||||
|
const props = await sanitizeRendererProperties("ao.renderer.orchestrator_spawn_requested", {
|
||||||
|
project_id: "demo-project",
|
||||||
|
source: "board",
|
||||||
|
});
|
||||||
|
expect(Object.keys(props).sort()).toEqual(["project_id_hash", "source"]);
|
||||||
|
expect(props.source).toBe("board");
|
||||||
|
|
||||||
|
const badSource = await sanitizeRendererProperties("ao.renderer.orchestrator_spawn_failed", {
|
||||||
|
project_id: "demo-project",
|
||||||
|
source: "/Users/alice/private",
|
||||||
|
});
|
||||||
|
expect(badSource).not.toHaveProperty("source");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every whitelisted spawn source, including topbar/sidebar/project_add/settings/restart", async () => {
|
||||||
|
for (const source of ["board", "restore_dialog", "topbar", "sidebar", "project_add", "settings", "restart"]) {
|
||||||
|
const props = await sanitizeRendererProperties("ao.renderer.orchestrator_spawn_succeeded", {
|
||||||
|
project_id: "demo-project",
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
expect(Object.keys(props).sort()).toEqual(["project_id_hash", "source"]);
|
||||||
|
expect(props.source).toBe(source);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only enum values on notification events", async () => {
|
||||||
|
expect(await sanitizeRendererProperties("ao.renderer.notification_opened", { target: "pr" })).toEqual({
|
||||||
|
target: "pr",
|
||||||
|
});
|
||||||
|
expect(await sanitizeRendererProperties("ao.renderer.notification_opened", { target: "http://x" })).toEqual({});
|
||||||
|
expect(await sanitizeRendererProperties("ao.renderer.notification_marked_read", { scope: "all" })).toEqual({
|
||||||
|
scope: "all",
|
||||||
|
});
|
||||||
|
expect(await sanitizeRendererProperties("ao.renderer.notification_marked_read", { scope: "everything" })).toEqual(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("whitelists coarse daemon failure fields and drops messages", async () => {
|
||||||
|
const props = await sanitizeRendererProperties("ao.renderer.daemon_failure", {
|
||||||
|
daemon_state: "error",
|
||||||
|
code: "spawn_failed",
|
||||||
|
exit_code: 1,
|
||||||
|
signal: "SIGKILL",
|
||||||
|
message: "spawn /Users/alice/ao failed",
|
||||||
|
});
|
||||||
|
expect(props).toEqual({ daemon_state: "error", code: "spawn_failed", exit_code: 1, signal: "SIGKILL" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("whitelists normalized api_error fields", async () => {
|
||||||
|
const props = await sanitizeRendererProperties("ao.renderer.api_error", {
|
||||||
|
operation: "GET /api/v1/projects/:id",
|
||||||
|
error_category: "http_5xx",
|
||||||
|
status: 500,
|
||||||
|
body: "raw response body",
|
||||||
|
});
|
||||||
|
expect(props).toEqual({ operation: "GET /api/v1/projects/:id", error_category: "http_5xx", status: 500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only the reason enum on terminal_attach_failed", async () => {
|
||||||
|
expect(await sanitizeRendererProperties("ao.renderer.terminal_attach_failed", { reason: "open_timeout" })).toEqual({
|
||||||
|
reason: "open_timeout",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await sanitizeRendererProperties("ao.renderer.terminal_attach_failed", { reason: "something else" }),
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,19 @@ async function sanitizeRendererContextProperties(properties?: TelemetryPropertie
|
||||||
return safe;
|
return safe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allowed `source` enum for the orchestrator-spawn triad. Kept as a literal set
|
||||||
|
// here (rather than imported from spawn-orchestrator.ts, which imports this
|
||||||
|
// module) to avoid a cycle; keep in sync with OrchestratorSpawnSource.
|
||||||
|
const ORCHESTRATOR_SPAWN_SOURCES = new Set([
|
||||||
|
"board",
|
||||||
|
"restore_dialog",
|
||||||
|
"topbar",
|
||||||
|
"sidebar",
|
||||||
|
"project_add",
|
||||||
|
"settings",
|
||||||
|
"restart",
|
||||||
|
]);
|
||||||
|
|
||||||
export async function sanitizeRendererProperties(
|
export async function sanitizeRendererProperties(
|
||||||
event: string,
|
event: string,
|
||||||
properties?: TelemetryProperties,
|
properties?: TelemetryProperties,
|
||||||
|
|
@ -147,11 +160,52 @@ export async function sanitizeRendererProperties(
|
||||||
break;
|
break;
|
||||||
case "ao.renderer.project_add_succeeded":
|
case "ao.renderer.project_add_succeeded":
|
||||||
case "ao.renderer.project_removed":
|
case "ao.renderer.project_removed":
|
||||||
case "ao.renderer.orchestrator_open_requested": {
|
case "ao.renderer.orchestrator_open_requested":
|
||||||
|
case "ao.renderer.task_create_requested":
|
||||||
|
case "ao.renderer.task_create_succeeded":
|
||||||
|
case "ao.renderer.task_create_failed":
|
||||||
|
case "ao.renderer.session_kill_requested":
|
||||||
|
case "ao.renderer.session_kill_succeeded":
|
||||||
|
case "ao.renderer.session_kill_failed":
|
||||||
|
case "ao.renderer.settings_save_requested":
|
||||||
|
case "ao.renderer.settings_save_succeeded":
|
||||||
|
case "ao.renderer.settings_save_failed": {
|
||||||
const projectIDHash = await hashedTelemetryID(properties?.project_id);
|
const projectIDHash = await hashedTelemetryID(properties?.project_id);
|
||||||
if (projectIDHash) safe.project_id_hash = projectIDHash;
|
if (projectIDHash) safe.project_id_hash = projectIDHash;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "ao.renderer.orchestrator_spawn_requested":
|
||||||
|
case "ao.renderer.orchestrator_spawn_succeeded":
|
||||||
|
case "ao.renderer.orchestrator_spawn_failed": {
|
||||||
|
const projectIDHash = await hashedTelemetryID(properties?.project_id);
|
||||||
|
if (projectIDHash) safe.project_id_hash = projectIDHash;
|
||||||
|
if (typeof properties?.source === "string" && ORCHESTRATOR_SPAWN_SOURCES.has(properties.source)) {
|
||||||
|
safe.source = properties.source;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ao.renderer.notification_opened":
|
||||||
|
if (properties?.target === "pr" || properties?.target === "session") safe.target = properties.target;
|
||||||
|
break;
|
||||||
|
case "ao.renderer.notification_marked_read":
|
||||||
|
if (properties?.scope === "single" || properties?.scope === "all") safe.scope = properties.scope;
|
||||||
|
break;
|
||||||
|
case "ao.renderer.daemon_failure":
|
||||||
|
if (typeof properties?.daemon_state === "string") safe.daemon_state = properties.daemon_state;
|
||||||
|
if (typeof properties?.code === "string") safe.code = properties.code;
|
||||||
|
if (typeof properties?.exit_code === "number") safe.exit_code = properties.exit_code;
|
||||||
|
if (typeof properties?.signal === "string") safe.signal = properties.signal;
|
||||||
|
break;
|
||||||
|
case "ao.renderer.api_error":
|
||||||
|
if (typeof properties?.operation === "string") safe.operation = properties.operation;
|
||||||
|
if (typeof properties?.error_category === "string") safe.error_category = properties.error_category;
|
||||||
|
if (typeof properties?.status === "number") safe.status = properties.status;
|
||||||
|
break;
|
||||||
|
case "ao.renderer.terminal_attach_failed":
|
||||||
|
if (properties?.reason === "open_timeout" || properties?.reason === "pane_error") {
|
||||||
|
safe.reason = properties.reason;
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return safe;
|
return safe;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ import { queryClient } from "./lib/query-client";
|
||||||
import { createAppRouter } from "./router";
|
import { createAppRouter } from "./router";
|
||||||
import { TelemetryBoundary } from "./components/TelemetryBoundary";
|
import { TelemetryBoundary } from "./components/TelemetryBoundary";
|
||||||
import { initTelemetry } from "./lib/telemetry";
|
import { initTelemetry } from "./lib/telemetry";
|
||||||
|
import { startDaemonFailureTelemetry } from "./lib/daemon-telemetry";
|
||||||
|
|
||||||
const router = createAppRouter(queryClient);
|
const router = createAppRouter(queryClient);
|
||||||
void initTelemetry();
|
void initTelemetry();
|
||||||
|
startDaemonFailureTelemetry();
|
||||||
|
|
||||||
declare module "@tanstack/react-router" {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ function ShellLayout() {
|
||||||
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
|
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
|
||||||
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]);
|
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]);
|
||||||
try {
|
try {
|
||||||
const sessionId = await spawnOrchestrator(workspace.id);
|
const sessionId = await spawnOrchestrator(workspace.id, "project_add");
|
||||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||||
void navigate({
|
void navigate({
|
||||||
to: "/projects/$projectId/sessions/$sessionId",
|
to: "/projects/$projectId/sessions/$sessionId",
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,7 @@ describe("resolveDaemonFromRunFile", () => {
|
||||||
executablePath: undefined,
|
executablePath: undefined,
|
||||||
workingDirectory: undefined,
|
workingDirectory: undefined,
|
||||||
message: "An AO daemon is already running, but it is not ready yet.",
|
message: "An AO daemon is already running, but it is not ready yet.",
|
||||||
|
code: "not_ready",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -271,6 +272,7 @@ describe("resolveDaemonFromPort", () => {
|
||||||
executablePath: undefined,
|
executablePath: undefined,
|
||||||
workingDirectory: undefined,
|
workingDirectory: undefined,
|
||||||
message: "An AO daemon is already running, but it is not ready yet.",
|
message: "An AO daemon is already running, but it is not ready yet.",
|
||||||
|
code: "not_ready",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@ async function readinessStatus(
|
||||||
executablePath: health.executablePath,
|
executablePath: health.executablePath,
|
||||||
workingDirectory: health.workingDirectory,
|
workingDirectory: health.workingDirectory,
|
||||||
message: "An AO daemon is already running, but it is not ready yet.",
|
message: "An AO daemon is already running, but it is not ready yet.",
|
||||||
|
code: "not_ready",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,6 +160,7 @@ async function readinessStatus(
|
||||||
executablePath: ready.executablePath,
|
executablePath: ready.executablePath,
|
||||||
workingDirectory: ready.workingDirectory,
|
workingDirectory: ready.workingDirectory,
|
||||||
message,
|
message,
|
||||||
|
code: "identity_mismatch",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,19 @@
|
||||||
// DaemonStatus is the supervisor → renderer handshake payload, shared by the
|
// DaemonStatus is the supervisor → renderer handshake payload, shared by the
|
||||||
// Electron main process (which derives it) and the preload bridge (which types
|
// Electron main process (which derives it) and the preload bridge (which types
|
||||||
// the IPC surface). The renderer picks it up through the preload's AoBridge type.
|
// the IPC surface). The renderer picks it up through the preload's AoBridge type.
|
||||||
|
// Machine-readable failure classification for telemetry. `message` is
|
||||||
|
// human-facing and may contain local paths; `code` is what gets reported.
|
||||||
|
// Statuses without a code (normal ready, user-initiated stop) are not failures.
|
||||||
|
export type DaemonFailureCode =
|
||||||
|
| "not_configured"
|
||||||
|
| "daemon_unreachable"
|
||||||
|
| "binary_missing"
|
||||||
|
| "spawn_failed"
|
||||||
|
| "exited"
|
||||||
|
| "port_unconfirmed"
|
||||||
|
| "not_ready"
|
||||||
|
| "identity_mismatch";
|
||||||
|
|
||||||
export type DaemonStatus = {
|
export type DaemonStatus = {
|
||||||
state: "starting" | "ready" | "stopped" | "error";
|
state: "starting" | "ready" | "stopped" | "error";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
|
@ -8,4 +21,7 @@ export type DaemonStatus = {
|
||||||
executablePath?: string;
|
executablePath?: string;
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
code?: DaemonFailureCode;
|
||||||
|
exitCode?: number | null;
|
||||||
|
signal?: string | null;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue