* fix(daemon): log stop reasons and client identity Record websocket client identity, process memory/uptime, and shutdown reasons across CLI, desktop, supervisor, and worker paths so daemon drops can be traced from the triggering client to worker termination. * fix(daemon): keep shutdown diagnostics in sync Test drift: supervisor and relay tests still asserted the old log text and metadata shape after shutdown diagnostics started logging structured reasons and relay connection ids. Update those assertions to the new diagnostic contract. Also centralize client lifecycle reason normalization and derive desktop daemon stop reasons from one tuple so future changes cannot silently drift.
297 lines
8.9 KiB
TypeScript
297 lines
8.9 KiB
TypeScript
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
|
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
|
|
|
export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
|
export type DesktopDaemonStopReason =
|
|
| "manual_ipc"
|
|
| "settings"
|
|
| "host_remove"
|
|
| "quit"
|
|
| "app_update"
|
|
| "version_mismatch"
|
|
| "restart";
|
|
|
|
export interface DesktopDaemonStatus {
|
|
serverId: string;
|
|
status: DesktopDaemonState;
|
|
listen: string | null;
|
|
hostname: string | null;
|
|
pid: number | null;
|
|
home: string;
|
|
version: string | null;
|
|
desktopManaged: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
export interface DesktopDaemonLogs {
|
|
logPath: string;
|
|
contents: string;
|
|
}
|
|
|
|
export interface DesktopPairingOffer {
|
|
relayEnabled: boolean;
|
|
url: string | null;
|
|
qr: string | null;
|
|
}
|
|
|
|
export interface LocalTransportTarget {
|
|
[key: string]: unknown;
|
|
transportType: "socket" | "pipe";
|
|
transportPath: string;
|
|
}
|
|
|
|
interface LocalTransportEventPayload {
|
|
sessionId: string;
|
|
kind: "open" | "message" | "close" | "error";
|
|
text?: string | null;
|
|
binaryBase64?: string | null;
|
|
code?: number | null;
|
|
reason?: string | null;
|
|
error?: string | null;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function toStringOrNull(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
}
|
|
|
|
function toNumberOrNull(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function parseDesktopDaemonState(value: unknown): DesktopDaemonState {
|
|
const normalized = toStringOrNull(value)?.toLowerCase();
|
|
switch (normalized) {
|
|
case "starting":
|
|
return "starting";
|
|
case "running":
|
|
return "running";
|
|
case "errored":
|
|
case "error":
|
|
return "errored";
|
|
case "stopped":
|
|
case "stopping":
|
|
case "unknown":
|
|
default:
|
|
return "stopped";
|
|
}
|
|
}
|
|
|
|
function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
|
|
if (!isRecord(raw)) {
|
|
throw new Error("Unexpected desktop daemon status response.");
|
|
}
|
|
return {
|
|
serverId: toStringOrNull(raw.serverId) ?? "",
|
|
status: parseDesktopDaemonState(raw.status),
|
|
listen: toStringOrNull(raw.listen),
|
|
hostname: toStringOrNull(raw.hostname),
|
|
pid: toNumberOrNull(raw.pid),
|
|
home: toStringOrNull(raw.home) ?? "",
|
|
version: toStringOrNull(raw.version),
|
|
desktopManaged: raw.desktopManaged === true,
|
|
error: toStringOrNull(raw.error),
|
|
};
|
|
}
|
|
|
|
function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs {
|
|
if (!isRecord(raw)) {
|
|
throw new Error("Unexpected desktop daemon logs response.");
|
|
}
|
|
return {
|
|
logPath: toStringOrNull(raw.logPath) ?? "",
|
|
contents: typeof raw.contents === "string" ? raw.contents : "",
|
|
};
|
|
}
|
|
|
|
function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
|
|
if (!isRecord(raw)) {
|
|
throw new Error("Unexpected desktop daemon pairing response.");
|
|
}
|
|
return {
|
|
relayEnabled: raw.relayEnabled === true,
|
|
url: toStringOrNull(raw.url),
|
|
qr: toStringOrNull(raw.qr),
|
|
};
|
|
}
|
|
|
|
export function shouldUseDesktopDaemon(): boolean {
|
|
return isElectronRuntime();
|
|
}
|
|
|
|
export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
|
|
return parseDesktopDaemonStatus(await invokeDesktopCommand("desktop_daemon_status"));
|
|
}
|
|
|
|
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
|
return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon"));
|
|
}
|
|
|
|
export async function stopDesktopDaemon(
|
|
reason: DesktopDaemonStopReason = "manual_ipc",
|
|
): Promise<DesktopDaemonStatus> {
|
|
return parseDesktopDaemonStatus(await invokeDesktopCommand("stop_desktop_daemon", { reason }));
|
|
}
|
|
|
|
export async function restartDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
|
return parseDesktopDaemonStatus(await invokeDesktopCommand("restart_desktop_daemon"));
|
|
}
|
|
|
|
export async function getDesktopDaemonLogs(): Promise<DesktopDaemonLogs> {
|
|
return parseDesktopDaemonLogs(await invokeDesktopCommand("desktop_daemon_logs"));
|
|
}
|
|
|
|
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
|
|
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
|
|
}
|
|
|
|
export async function getCliDaemonStatus(): Promise<string> {
|
|
const raw = await invokeDesktopCommand<unknown>("cli_daemon_status");
|
|
if (typeof raw !== "string") {
|
|
throw new Error("Unexpected CLI daemon status response.");
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
export type LocalTransportEventUnlisten = () => void;
|
|
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void;
|
|
|
|
export async function listenToLocalTransportEvents(
|
|
handler: LocalTransportEventHandler,
|
|
): Promise<LocalTransportEventUnlisten> {
|
|
const listen = getDesktopHost()?.events?.on;
|
|
if (typeof listen !== "function") {
|
|
throw new Error("Desktop events API is unavailable.");
|
|
}
|
|
const unlisten = await listen("local-daemon-transport-event", (payload: unknown) => {
|
|
if (!isRecord(payload)) {
|
|
return;
|
|
}
|
|
handler({
|
|
sessionId: toStringOrNull(payload.sessionId) ?? "",
|
|
kind: (toStringOrNull(payload.kind) ?? "error") as LocalTransportEventPayload["kind"],
|
|
text: toStringOrNull(payload.text),
|
|
binaryBase64: toStringOrNull(payload.binaryBase64),
|
|
code: toNumberOrNull(payload.code),
|
|
reason: toStringOrNull(payload.reason),
|
|
error: toStringOrNull(payload.error),
|
|
});
|
|
});
|
|
return typeof unlisten === "function" ? unlisten : () => {};
|
|
}
|
|
|
|
export async function openLocalTransportSession(target: LocalTransportTarget): Promise<string> {
|
|
const raw = await invokeDesktopCommand<unknown>("open_local_daemon_transport", target);
|
|
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
throw new Error("Unexpected local transport session response.");
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
export async function sendLocalTransportMessage(input: {
|
|
sessionId: string;
|
|
text?: string;
|
|
binaryBase64?: string;
|
|
}): Promise<void> {
|
|
await invokeDesktopCommand("send_local_daemon_transport_message", {
|
|
sessionId: input.sessionId,
|
|
...(input.text ? { text: input.text } : {}),
|
|
...(input.binaryBase64 ? { binaryBase64: input.binaryBase64 } : {}),
|
|
});
|
|
}
|
|
|
|
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
|
|
await invokeDesktopCommand("close_local_daemon_transport", { sessionId });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integrations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface InstallStatus {
|
|
installed: boolean;
|
|
}
|
|
|
|
function parseInstallStatus(raw: unknown): InstallStatus {
|
|
if (!isRecord(raw)) {
|
|
throw new Error("Unexpected install status response.");
|
|
}
|
|
return { installed: raw.installed === true };
|
|
}
|
|
|
|
export async function getCliInstallStatus(): Promise<InstallStatus> {
|
|
return parseInstallStatus(await invokeDesktopCommand("get_cli_install_status"));
|
|
}
|
|
|
|
export async function installCli(): Promise<InstallStatus> {
|
|
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
|
|
}
|
|
|
|
export type SkillsState = "not-installed" | "up-to-date" | "drift";
|
|
|
|
export type SkillOp =
|
|
| { kind: "add"; name: string }
|
|
| { kind: "update"; name: string }
|
|
| { kind: "delete"; name: string };
|
|
|
|
export interface SkillsStatus {
|
|
state: SkillsState;
|
|
ops: SkillOp[];
|
|
}
|
|
|
|
function parseSkillsState(value: unknown): SkillsState {
|
|
switch (value) {
|
|
case "not-installed":
|
|
case "up-to-date":
|
|
case "drift":
|
|
return value;
|
|
default:
|
|
throw new Error(`Unexpected skills status state: ${String(value)}`);
|
|
}
|
|
}
|
|
|
|
function parseSkillOp(raw: unknown): SkillOp {
|
|
if (!isRecord(raw)) {
|
|
throw new Error("Unexpected skill op response.");
|
|
}
|
|
const name = toStringOrNull(raw.name);
|
|
if (!name) throw new Error("Skill op missing name.");
|
|
switch (raw.kind) {
|
|
case "add":
|
|
return { kind: "add", name };
|
|
case "update":
|
|
return { kind: "update", name };
|
|
case "delete":
|
|
return { kind: "delete", name };
|
|
default:
|
|
throw new Error(`Unexpected skill op kind: ${String(raw.kind)}`);
|
|
}
|
|
}
|
|
|
|
function parseSkillsStatus(raw: unknown): SkillsStatus {
|
|
if (!isRecord(raw)) {
|
|
throw new Error("Unexpected skills status response.");
|
|
}
|
|
const ops = Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : [];
|
|
return { state: parseSkillsState(raw.state), ops };
|
|
}
|
|
|
|
export async function getSkillsStatus(): Promise<SkillsStatus> {
|
|
return parseSkillsStatus(await invokeDesktopCommand("get_skills_status"));
|
|
}
|
|
|
|
export async function installSkills(): Promise<SkillsStatus> {
|
|
return parseSkillsStatus(await invokeDesktopCommand("install_skills"));
|
|
}
|
|
|
|
export async function updateSkills(): Promise<SkillsStatus> {
|
|
return parseSkillsStatus(await invokeDesktopCommand("update_skills"));
|
|
}
|
|
|
|
export async function uninstallSkills(): Promise<SkillsStatus> {
|
|
return parseSkillsStatus(await invokeDesktopCommand("uninstall_skills"));
|
|
}
|