diff --git a/package-lock.json b/package-lock.json index b0c3c933..e13e8db1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3753,6 +3753,7 @@ "name": "@argent/cli", "version": "0.10.0", "dependencies": { + "@argent/registry": "file:../registry", "@argent/telemetry": "file:../telemetry", "@argent/tools-client": "file:../argent-tools-client" }, @@ -3797,6 +3798,7 @@ "name": "@argent/installer", "version": "0.10.0", "dependencies": { + "@argent/registry": "file:../registry", "@argent/telemetry": "file:../telemetry", "@argent/tools-client": "file:../argent-tools-client", "@argent/update-core": "file:../update-core", @@ -4103,6 +4105,7 @@ "name": "@argent/telemetry", "version": "0.10.0", "dependencies": { + "@argent/registry": "file:../registry", "ci-info": "^4.4.0", "posthog-node": "5.35.0" }, diff --git a/packages/argent-cli/package.json b/packages/argent-cli/package.json index 32ff29d4..9c198595 100644 --- a/packages/argent-cli/package.json +++ b/packages/argent-cli/package.json @@ -12,6 +12,7 @@ "typecheck:tests": "tsc --noEmit -p tsconfig.test.json" }, "dependencies": { + "@argent/registry": "file:../registry", "@argent/telemetry": "file:../telemetry", "@argent/tools-client": "file:../argent-tools-client", "@clack/prompts": "^1.1.0", diff --git a/packages/argent-cli/src/run.ts b/packages/argent-cli/src/run.ts index 451308af..43226b76 100644 --- a/packages/argent-cli/src/run.ts +++ b/packages/argent-cli/src/run.ts @@ -1,6 +1,8 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { createToolsClient, type ToolMeta, type ToolsServerPaths } from "@argent/tools-client"; +import { init as telemetryInit, shutdown as telemetryShutdown, track } from "@argent/telemetry"; +import { FAILURE_CODES, type FailureCode, type FailureKind } from "@argent/registry"; import { parseFlags, formatSchemaUsage, @@ -109,7 +111,33 @@ function renderResult(result: unknown, outputHint: string | undefined, json: boo return JSON.stringify(result, null, 2); } +const SAFE_TOOL_RE = /^[a-z][a-z0-9-]{0,63}$/; + +function safeToolName(toolName: string | undefined): string { + return toolName && SAFE_TOOL_RE.test(toolName) ? toolName : "unknown"; +} + +async function trackRunFailure( + toolName: string | undefined, + startedAt: number, + signal: { + error_code: FailureCode; + failure_stage: string; + failure_area: "cli"; + error_kind: FailureKind; + } +): Promise { + track("cli:run_fail", { + tool: safeToolName(toolName), + duration_ms: performance.now() - startedAt, + ...signal, + }); + await telemetryShutdown(); +} + export async function run(argv: string[], options: RunCommandOptions): Promise { + telemetryInit("cli"); + const startedAt = performance.now(); const { fetchTool, callTool } = createToolsClient({ paths: options.paths }); const [toolName, ...rest] = argv; @@ -134,6 +162,12 @@ Examples: const meta = await fetchTool(toolName); if (!meta) { console.error(`Tool "${toolName}" not found. Run \`argent tools\` to list available tools.`); + await trackRunFailure(toolName, startedAt, { + error_code: FAILURE_CODES.CLI_RUN_TOOL_NOT_FOUND, + failure_stage: "cli_run_fetch_tool", + failure_area: "cli", + error_kind: "not_found", + }); process.exit(1); } @@ -144,6 +178,12 @@ Examples: if (err instanceof FlagParseException) { console.error(`Error: ${err.message}\n`); printToolHelp(meta); + await trackRunFailure(toolName, startedAt, { + error_code: FAILURE_CODES.CLI_RUN_FLAG_PARSE_FAILED, + failure_stage: "cli_run_parse_flags", + failure_area: "cli", + error_kind: "validation", + }); process.exit(2); } throw err; @@ -167,11 +207,23 @@ Examples: const parsedRaw = JSON.parse(rawJson); if (parsedRaw === null || typeof parsedRaw !== "object" || Array.isArray(parsedRaw)) { console.error("--args must be a JSON object"); + await trackRunFailure(toolName, startedAt, { + error_code: FAILURE_CODES.CLI_RUN_ARGS_NOT_OBJECT, + failure_stage: "cli_run_parse_raw_args", + failure_area: "cli", + error_kind: "validation", + }); process.exit(2); } payload = parsedRaw as Record; } catch (err) { console.error(`--args is not valid JSON: ${err instanceof Error ? err.message : err}`); + await trackRunFailure(toolName, startedAt, { + error_code: FAILURE_CODES.CLI_RUN_ARGS_JSON_INVALID, + failure_stage: "cli_run_parse_raw_args", + failure_area: "cli", + error_kind: "validation", + }); process.exit(2); } } @@ -187,6 +239,12 @@ Examples: note = resp.note; } catch (err) { console.error(err instanceof Error ? err.message : String(err)); + await trackRunFailure(toolName, startedAt, { + error_code: FAILURE_CODES.CLI_RUN_TOOL_CALL_FAILED, + failure_stage: "cli_run_call_tool", + failure_area: "cli", + error_kind: "unknown", + }); process.exit(1); } @@ -197,6 +255,12 @@ Examples: await fetchImageToFile(result as { url?: string; path?: string }, outPath); } catch (err) { console.error(`Failed to save image: ${err instanceof Error ? err.message : err}`); + await trackRunFailure(toolName, startedAt, { + error_code: FAILURE_CODES.CLI_RUN_SAVE_IMAGE_FAILED, + failure_stage: "cli_run_save_image", + failure_area: "cli", + error_kind: "unknown", + }); process.exit(1); } } diff --git a/packages/argent-cli/test/run-telemetry.test.ts b/packages/argent-cli/test/run-telemetry.test.ts new file mode 100644 index 00000000..b35bc99b --- /dev/null +++ b/packages/argent-cli/test/run-telemetry.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { run } from "../src/run.js"; + +const toolsClientMock = vi.hoisted(() => ({ + fetchTool: vi.fn(), + callTool: vi.fn(), +})); + +const telemetryMock = vi.hoisted(() => ({ + init: vi.fn(), + shutdown: vi.fn(async () => undefined), + track: vi.fn(), +})); + +vi.mock("@argent/tools-client", () => ({ + createToolsClient: vi.fn(() => toolsClientMock), +})); + +vi.mock("@argent/telemetry", () => telemetryMock); + +const toolMeta = { + name: "sample-tool", + description: "Sample tool", + inputSchema: { + type: "object", + properties: {}, + }, +}; + +describe("argent run telemetry", () => { + let exitSpy: ReturnType; + let errorSpy: ReturnType; + let logSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + toolsClientMock.fetchTool.mockResolvedValue(toolMeta); + toolsClientMock.callTool.mockResolvedValue({ data: { ok: true } }); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`); + }) as typeof process.exit); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + }); + + afterEach(() => { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + logSpy.mockRestore(); + }); + + it("emits cli:run_fail, not tool:fail, when tool-server call fails", async () => { + toolsClientMock.callTool.mockRejectedValue(new Error("server already tracked this")); + + await expect(run(["sample-tool"], { paths: {} as never })).rejects.toThrow("process.exit:1"); + + expect(console.error).toHaveBeenCalledWith("server already tracked this"); + expect(telemetryMock.track).toHaveBeenCalledWith( + "cli:run_fail", + expect.objectContaining({ + tool: "sample-tool", + error_code: "CLI_RUN_TOOL_CALL_FAILED", + failure_stage: "cli_run_call_tool", + failure_area: "cli", + error_kind: "unknown", + }) + ); + expect(telemetryMock.track).not.toHaveBeenCalledWith("tool:fail", expect.anything()); + expect(telemetryMock.shutdown).toHaveBeenCalledTimes(1); + }); + + it("emits cli:run_fail, not tool:fail, for local CLI argument parsing failures", async () => { + await expect( + run(["sample-tool", "--args", "not-json"], { paths: {} as never }) + ).rejects.toThrow("process.exit:2"); + + expect(toolsClientMock.callTool).not.toHaveBeenCalled(); + expect(telemetryMock.track).toHaveBeenCalledWith( + "cli:run_fail", + expect.objectContaining({ + tool: "sample-tool", + error_code: "CLI_RUN_ARGS_JSON_INVALID", + failure_stage: "cli_run_parse_raw_args", + failure_area: "cli", + error_kind: "validation", + }) + ); + expect(telemetryMock.track).not.toHaveBeenCalledWith("tool:fail", expect.anything()); + expect(telemetryMock.shutdown).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/argent-installer/package.json b/packages/argent-installer/package.json index dfed0ca8..1a96d4be 100644 --- a/packages/argent-installer/package.json +++ b/packages/argent-installer/package.json @@ -12,6 +12,7 @@ "typecheck:tests": "tsc --noEmit -p tsconfig.test.json" }, "dependencies": { + "@argent/registry": "file:../registry", "@argent/telemetry": "file:../telemetry", "@argent/tools-client": "file:../argent-tools-client", "@argent/update-core": "file:../update-core", diff --git a/packages/argent-installer/src/init.ts b/packages/argent-installer/src/init.ts index ebaad186..c9f53a28 100644 --- a/packages/argent-installer/src/init.ts +++ b/packages/argent-installer/src/init.ts @@ -4,6 +4,7 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { spawn } from "node:child_process"; import { init as telemetryInit, track, isEnabled as telemetryIsEnabled } from "@argent/telemetry"; +import { FAILURE_CODES, type FailureSignal } from "@argent/registry"; import { detectAdapters, ALL_ADAPTERS, @@ -28,10 +29,51 @@ import { resolveProjectRoot, type ShellCommand, } from "./utils.js"; -import { refreshArgentSkills, formatSkillRefreshSummary } from "./skills.js"; +import { + refreshArgentSkills, + formatSkillRefreshSummary, + summarizeSkillRefreshForTelemetry, +} from "./skills.js"; import { PACKAGE_NAME } from "./constants.js"; import { finalizeTelemetry } from "./telemetry-finalize.js"; +type InstallerFailureSignal = FailureSignal & { failure_area: "installer" }; + +const INSTALL_GLOBAL_PACKAGE_FAILED: InstallerFailureSignal = { + error_code: FAILURE_CODES.INSTALL_GLOBAL_PACKAGE_FAILED, + failure_stage: "installer_global_package_install", + failure_area: "installer", + error_kind: "subprocess", +}; + +const INSTALL_FROM_TAR_PACKAGE_FAILED: InstallerFailureSignal = { + error_code: FAILURE_CODES.INSTALL_FROM_TAR_PACKAGE_FAILED, + failure_stage: "installer_from_tar_package_install", + failure_area: "installer", + error_kind: "subprocess", +}; + +const INSTALL_REGISTRY_CHECK_FAILED: InstallerFailureSignal = { + error_code: FAILURE_CODES.INSTALL_REGISTRY_CHECK_FAILED, + failure_stage: "installer_registry_update_check", + failure_area: "installer", + error_kind: "network", +}; + +const INSTALL_INIT_TRIGGERED_UPDATE_FAILED: InstallerFailureSignal = { + error_code: FAILURE_CODES.INSTALL_INIT_TRIGGERED_UPDATE_FAILED, + failure_stage: "installer_init_triggered_update", + failure_area: "installer", + error_kind: "subprocess", +}; + +const INSTALL_SKILLS_REFRESH_FAILED: InstallerFailureSignal = { + error_code: FAILURE_CODES.INSTALL_SKILLS_REFRESH_FAILED, + failure_stage: "installer_skills_refresh", + failure_area: "installer", + error_kind: "subprocess", +}; + function runShellCommand(cmd: ShellCommand): Promise { return new Promise((resolve, reject) => { const isWin = process.platform === "win32"; @@ -68,7 +110,7 @@ export async function init(args: string[]): Promise { telemetryInit("installer"); printTelemetryNotice(); - track("installation:cli_init_start", { + await track("installation:cli_init_start", { package_manager: detectPackageManager(), is_non_interactive: nonInteractive, }); @@ -76,7 +118,7 @@ export async function init(args: string[]): Promise { let editorsConfiguredCount = 0; let initSucceeded = false; let telemetryFinalized = false; - const finalizeInitTelemetry = async (): Promise => { + const finalizeInitTelemetry = async (failureSignal?: InstallerFailureSignal): Promise => { if (telemetryFinalized) return; telemetryFinalized = true; await finalizeTelemetry(() => { @@ -84,6 +126,7 @@ export async function init(args: string[]): Promise { duration_ms: performance.now() - initStartTime, is_success: initSucceeded, editors_configured_count: editorsConfiguredCount, + ...(failureSignal ?? {}), }); }); }; @@ -97,595 +140,614 @@ export async function init(args: string[]): Promise { | "update_skipped" | "update_failed", startedAt: number, - isSuccess: boolean + isSuccess: boolean, + failureSignal?: InstallerFailureSignal ): Promise => { - track("installation:package_action", { + await track("installation:package_action", { trigger: "init", action, is_success: isSuccess, duration_ms: performance.now() - startedAt, + ...(failureSignal ?? {}), }); }; - try { - printBanner(); + printBanner(); - p.intro(pc.bgCyan(pc.black(" argent init "))); + p.intro(pc.bgCyan(pc.black(" argent init "))); - let version = getInstalledVersion() ?? "unknown"; - p.log.info(`${pc.dim("Package:")} ${PACKAGE_NAME}@${version}`); + let version = getInstalledVersion() ?? "unknown"; + p.log.info(`${pc.dim("Package:")} ${PACKAGE_NAME}@${version}`); - // ── Step 0: Install / Update Check ────────────────────────────────────────── + // ── Step 0: Install / Update Check ────────────────────────────────────────── - const globallyInstalled = isGloballyInstalled(); + const globallyInstalled = isGloballyInstalled(); - if (!globallyInstalled) { - if (!nonInteractive) { - const installChoice = await p.select({ - message: "Argent is not installed globally. Would you like to install it?", + if (!globallyInstalled) { + if (!nonInteractive) { + const installChoice = await p.select({ + message: "Argent is not installed globally. Would you like to install it?", + options: [ + { + value: "global" as const, + label: "Install globally", + hint: "Makes the argent command available everywhere", + }, + { + value: "cancel" as const, + label: "Cancel installation", + }, + ], + }); + + if (p.isCancel(installChoice) || installChoice === "cancel") { + await track("installation:global_install_decision", { decision: "cancel" }); + await track("installation:cli_init_cancel", { step: "global_install" }); + await finalizeInitTelemetry(); + p.cancel("Installation cancelled."); + process.exit(0); + } + } + + await track("installation:global_install_decision", { decision: "install" }); + + const pm = detectPackageManager(); + const installTarget = fromTar ?? PACKAGE_NAME; + const cmd = globalInstallCommand(pm, installTarget); + const cmdStr = formatShellCommand(cmd); + const spinner = p.spinner(); + spinner.start(`Installing ${PACKAGE_NAME} globally...`); + const packageActionStartedAt = performance.now(); + try { + await runShellCommand(cmd); + spinner.stop(pc.green("Installed globally.")); + version = getInstalledVersion() ?? version; + await trackPackageAction("fresh_install", packageActionStartedAt, true); + } catch (err) { + spinner.stop(pc.red("Installation failed.")); + p.log.error(`${err}`); + p.log.info(`Install Argent manually with: ${pc.cyan(cmdStr)}`); + await trackPackageAction( + "fresh_install", + packageActionStartedAt, + false, + INSTALL_GLOBAL_PACKAGE_FAILED + ); + await finalizeInitTelemetry(INSTALL_GLOBAL_PACKAGE_FAILED); + process.exit(1); + } + } else if (fromTar) { + // Developer-only reinstall path; it is not a product install decision. + const pm = detectPackageManager(); + const cmd = globalInstallCommand(pm, fromTar); + const cmdStr = formatShellCommand(cmd); + const spinner = p.spinner(); + spinner.start(`Installing from ${fromTar}...`); + try { + await runShellCommand(cmd); + spinner.stop(pc.green("Installed from tarball.")); + version = getInstalledVersion() ?? version; + } catch (err) { + spinner.stop(pc.red("Installation failed.")); + p.log.error(`${err}`); + p.log.info(`Install manually with: ${pc.cyan(cmdStr)}`); + await finalizeInitTelemetry(INSTALL_FROM_TAR_PACKAGE_FAILED); + process.exit(1); + } + } else { + const packageActionStartedAt = performance.now(); + await track("installation:global_install_decision", { decision: "already_installed" }); + await trackPackageAction("already_installed", packageActionStartedAt, true); + let latest: string | null = null; + const spinner = p.spinner(); + spinner.start("Checking for updates..."); + try { + latest = getLatestVersion(); + } catch { + await trackPackageAction( + "update_skipped", + packageActionStartedAt, + false, + INSTALL_REGISTRY_CHECK_FAILED + ); + // Registry unreachable - silently skip + } + spinner.stop(pc.dim("Version check complete.")); + + if (latest && isNewerVersion(latest, version)) { + const fromMajor = Number.parseInt(version.split(".")[0] ?? "0", 10) || 0; + const toMajor = Number.parseInt(latest.split(".")[0] ?? "0", 10) || 0; + if (nonInteractive) { + await trackPackageAction("update_skipped", packageActionStartedAt, true); + } else { + const updateChoice = await p.select({ + message: `Update available: ${pc.yellow(`v${version}`)} → ${pc.green(`v${latest}`)}`, options: [ { - value: "global" as const, - label: "Install globally", - hint: "Makes the argent command available everywhere", + value: "update" as const, + label: `Update to v${latest} (recommended)`, }, { - value: "cancel" as const, - label: "Cancel installation", + value: "skip" as const, + label: "Skip", + hint: "Continue with current version", }, ], }); - if (p.isCancel(installChoice) || installChoice === "cancel") { - track("installation:global_install_decision", { decision: "cancel" }); - track("installation:cli_init_cancel", { step: "global_install" }); - await finalizeInitTelemetry(); - p.cancel("Installation cancelled."); - process.exit(0); - } - } - - track("installation:global_install_decision", { decision: "install" }); - - const pm = detectPackageManager(); - const installTarget = fromTar ?? PACKAGE_NAME; - const cmd = globalInstallCommand(pm, installTarget); - const cmdStr = formatShellCommand(cmd); - const spinner = p.spinner(); - spinner.start(`Installing ${PACKAGE_NAME} globally...`); - const packageActionStartedAt = performance.now(); - try { - await runShellCommand(cmd); - spinner.stop(pc.green("Installed globally.")); - version = getInstalledVersion() ?? version; - await trackPackageAction("fresh_install", packageActionStartedAt, true); - } catch (err) { - spinner.stop(pc.red("Installation failed.")); - p.log.error(`${err}`); - p.log.info(`Install Argent manually with: ${pc.cyan(cmdStr)}`); - await trackPackageAction("fresh_install", packageActionStartedAt, false); - await finalizeInitTelemetry(); - process.exit(1); - } - } else if (fromTar) { - // Developer-only reinstall path; it is not a product install decision. - const pm = detectPackageManager(); - const cmd = globalInstallCommand(pm, fromTar); - const cmdStr = formatShellCommand(cmd); - const spinner = p.spinner(); - spinner.start(`Installing from ${fromTar}...`); - try { - await runShellCommand(cmd); - spinner.stop(pc.green("Installed from tarball.")); - version = getInstalledVersion() ?? version; - } catch (err) { - spinner.stop(pc.red("Installation failed.")); - p.log.error(`${err}`); - p.log.info(`Install manually with: ${pc.cyan(cmdStr)}`); - await finalizeInitTelemetry(); - process.exit(1); - } - } else { - const packageActionStartedAt = performance.now(); - track("installation:global_install_decision", { decision: "already_installed" }); - await trackPackageAction("already_installed", packageActionStartedAt, true); - let latest: string | null = null; - const spinner = p.spinner(); - spinner.start("Checking for updates..."); - try { - latest = getLatestVersion(); - } catch { - await trackPackageAction("update_skipped", packageActionStartedAt, false); - // Registry unreachable - silently skip - } - spinner.stop(pc.dim("Version check complete.")); + await track("installation:update_decision", { + from_major: fromMajor, + to_major: toMajor, + decision: p.isCancel(updateChoice) ? "skip" : (updateChoice as "update" | "skip"), + }); - if (latest && isNewerVersion(latest, version)) { - const fromMajor = Number.parseInt(version.split(".")[0] ?? "0", 10) || 0; - const toMajor = Number.parseInt(latest.split(".")[0] ?? "0", 10) || 0; - if (nonInteractive) { + if (p.isCancel(updateChoice) || updateChoice === "skip") { await trackPackageAction("update_skipped", packageActionStartedAt, true); - } else { - const updateChoice = await p.select({ - message: `Update available: ${pc.yellow(`v${version}`)} → ${pc.green(`v${latest}`)}`, - options: [ - { - value: "update" as const, - label: `Update to v${latest} (recommended)`, - }, - { - value: "skip" as const, - label: "Skip", - hint: "Continue with current version", - }, - ], - }); - - track("installation:update_decision", { - from_major: fromMajor, - to_major: toMajor, - decision: p.isCancel(updateChoice) ? "skip" : (updateChoice as "update" | "skip"), - }); - - if (p.isCancel(updateChoice) || updateChoice === "skip") { - await trackPackageAction("update_skipped", packageActionStartedAt, true); - } else if (updateChoice === "update") { - const pm = detectPackageManager(); - const cmd = globalInstallCommand(pm, `${PACKAGE_NAME}@${latest}`); - const cmdStr = formatShellCommand(cmd); - const updateSpinner = p.spinner(); - updateSpinner.start(`Updating to v${latest}...`); - const updateStartedAt = performance.now(); - try { - await runShellCommand(cmd); - updateSpinner.stop(pc.green(`Updated to v${latest}.`)); - version = getInstalledVersion() ?? version; - await trackPackageAction("init_triggered_update", updateStartedAt, true); - - // The user just bumped to a newer argent. Re-sync and prune - // argent skills in every scope that already tracks them — this - // is the only point in init where we can surface orphans - // (skills removed from a previous argent version) before - // Step 2's single-scope `skills add`. - const skillSummary = formatSkillRefreshSummary( - refreshArgentSkills(resolveProjectRoot(process.cwd())) - ); - if (skillSummary) { - p.note(skillSummary, "Skills Updated"); - } - } catch (err) { - updateSpinner.stop(pc.red("Update failed.")); - p.log.error(`${err}`); - p.log.info(`You can update manually later: ${pc.cyan(cmdStr)}`); - await trackPackageAction("update_failed", updateStartedAt, false); + } else if (updateChoice === "update") { + const pm = detectPackageManager(); + const cmd = globalInstallCommand(pm, `${PACKAGE_NAME}@${latest}`); + const cmdStr = formatShellCommand(cmd); + const updateSpinner = p.spinner(); + updateSpinner.start(`Updating to v${latest}...`); + const updateStartedAt = performance.now(); + try { + await runShellCommand(cmd); + updateSpinner.stop(pc.green(`Updated to v${latest}.`)); + version = getInstalledVersion() ?? version; + await trackPackageAction("init_triggered_update", updateStartedAt, true); + + // The user just bumped to a newer argent. Re-sync and prune + // argent skills in every scope that already tracks them — this + // is the only point in init where we can surface orphans + // (skills removed from a previous argent version) before + // Step 2's single-scope `skills add`. + const skillRefreshResults = refreshArgentSkills(resolveProjectRoot(process.cwd())); + const skillSummary = formatSkillRefreshSummary(skillRefreshResults); + if (skillSummary) { + p.note(skillSummary, "Skills Updated"); + } + const skillTelemetrySummary = summarizeSkillRefreshForTelemetry(skillRefreshResults); + if (skillTelemetrySummary.scope_count > 0) { + await track("installation:skill_refresh_result", { + is_success: skillTelemetrySummary.failed_count === 0, + ...skillTelemetrySummary, + ...(skillTelemetrySummary.failed_count > 0 ? INSTALL_SKILLS_REFRESH_FAILED : {}), + }); } + } catch (err) { + updateSpinner.stop(pc.red("Update failed.")); + p.log.error(`${err}`); + p.log.info(`You can update manually later: ${pc.cyan(cmdStr)}`); + await trackPackageAction( + "update_failed", + updateStartedAt, + false, + INSTALL_INIT_TRIGGERED_UPDATE_FAILED + ); } } - } else if (latest) { - const fromMajor = Number.parseInt(version.split(".")[0] ?? "0", 10) || 0; - const toMajor = Number.parseInt(latest.split(".")[0] ?? "0", 10) || 0; - track("installation:update_decision", { - from_major: fromMajor, - to_major: toMajor, - decision: "no_update", - }); - await trackPackageAction("no_update", packageActionStartedAt, true); } + } else if (latest) { + const fromMajor = Number.parseInt(version.split(".")[0] ?? "0", 10) || 0; + const toMajor = Number.parseInt(latest.split(".")[0] ?? "0", 10) || 0; + await track("installation:update_decision", { + from_major: fromMajor, + to_major: toMajor, + decision: "no_update", + }); + await trackPackageAction("no_update", packageActionStartedAt, true); } + } - // ── Step 1: MCP Server Configuration ──────────────────────────────────────── + // ── Step 1: MCP Server Configuration ──────────────────────────────────────── - p.log.step(pc.bold("Step 1: MCP Server Configuration")); + p.log.step(pc.bold("Step 1: MCP Server Configuration")); - const detected = detectAdapters(); - const detectedNames = detected.map((a) => a.name); + const detected = detectAdapters(); + const detectedNames = detected.map((a) => a.name); - let selectedAdapters: McpConfigAdapter[]; + let selectedAdapters: McpConfigAdapter[]; - if (nonInteractive) { - selectedAdapters = detected.length > 0 ? detected : ALL_ADAPTERS; - } else { - const choices = ALL_ADAPTERS.map((a) => { - const parts: string[] = []; - if (detectedNames.includes(a.name)) parts.push("detected"); - const hasProject = a.projectPath(process.cwd()) != null; - const hasGlobal = a.globalPath() != null; - if (!hasProject && hasGlobal) { - parts.push(pc.italic(pc.cyan(`ⓘ will be installed into ${a.name}'s global config`))); - } else if (hasProject && !hasGlobal) { - parts.push(pc.italic(pc.cyan(`ⓘ will be installed into ${a.name}'s project config`))); - } - return { - value: a, - label: a.name, - hint: parts.length > 0 ? parts.join(", ") : undefined, - }; - }); - - p.log.message(pc.dim(" Use arrow keys to move, space to toggle, enter to confirm.")); + if (nonInteractive) { + selectedAdapters = detected.length > 0 ? detected : ALL_ADAPTERS; + } else { + const choices = ALL_ADAPTERS.map((a) => { + const parts: string[] = []; + if (detectedNames.includes(a.name)) parts.push("detected"); + const hasProject = a.projectPath(process.cwd()) != null; + const hasGlobal = a.globalPath() != null; + if (!hasProject && hasGlobal) { + parts.push(pc.italic(pc.cyan(`ⓘ will be installed into ${a.name}'s global config`))); + } else if (hasProject && !hasGlobal) { + parts.push(pc.italic(pc.cyan(`ⓘ will be installed into ${a.name}'s project config`))); + } + return { + value: a, + label: a.name, + hint: parts.length > 0 ? parts.join(", ") : undefined, + }; + }); - const selected = await p.multiselect({ - message: "Which editors should Argent be configured for?", - options: choices, - initialValues: detected, - required: true, - }); + p.log.message(pc.dim(" Use arrow keys to move, space to toggle, enter to confirm.")); - if (p.isCancel(selected)) { - track("installation:cli_init_cancel", { step: "editors" }); - await finalizeInitTelemetry(); - p.cancel("Initialization cancelled."); - process.exit(0); - } + const selected = await p.multiselect({ + message: "Which editors should Argent be configured for?", + options: choices, + initialValues: detected, + required: true, + }); - selectedAdapters = selected as McpConfigAdapter[]; + if (p.isCancel(selected)) { + await track("installation:cli_init_cancel", { step: "editors" }); + await finalizeInitTelemetry(); + p.cancel("Initialization cancelled."); + process.exit(0); } - editorsConfiguredCount = selectedAdapters.length; - p.log.info(`Editors: ${selectedAdapters.map((a) => pc.cyan(a.name)).join(", ")}`); + selectedAdapters = selected as McpConfigAdapter[]; + } - // Ask scope: global, local, or custom path - let scope: "local" | "global" | "custom"; - let customRoot: string | undefined; + editorsConfiguredCount = selectedAdapters.length; + p.log.info(`Editors: ${selectedAdapters.map((a) => pc.cyan(a.name)).join(", ")}`); + + // Ask scope: global, local, or custom path + let scope: "local" | "global" | "custom"; + let customRoot: string | undefined; + + if (nonInteractive) { + scope = "local"; + } else { + p.log.message(pc.dim(" Use arrow keys to move, enter to confirm.")); + + const scopeChoice = await p.select({ + message: "Install MCP server globally or locally?", + options: [ + { + value: "local" as const, + label: "Local", + hint: "Current project only - .cursor/mcp.json, .mcp.json, ...", + }, + { + value: "global" as const, + label: "Global", + hint: "Available across all projects - ~/.*/mcp.json", + }, + { + value: "custom" as const, + label: "Specify installation directory", + hint: "Specify a directory to use as the project root", + }, + ], + }); - if (nonInteractive) { - scope = "local"; - } else { - p.log.message(pc.dim(" Use arrow keys to move, enter to confirm.")); + if (p.isCancel(scopeChoice)) { + await track("installation:cli_init_cancel", { step: "scope" }); + await finalizeInitTelemetry(); + p.cancel("Initialization cancelled."); + process.exit(0); + } - const scopeChoice = await p.select({ - message: "Install MCP server globally or locally?", - options: [ - { - value: "local" as const, - label: "Local", - hint: "Current project only - .cursor/mcp.json, .mcp.json, ...", - }, - { - value: "global" as const, - label: "Global", - hint: "Available across all projects - ~/.*/mcp.json", - }, - { - value: "custom" as const, - label: "Specify installation directory", - hint: "Specify a directory to use as the project root", - }, - ], + scope = scopeChoice as "local" | "global" | "custom"; + + if (scope === "custom") { + const customPathInput = await p.text({ + message: "Enter the path to use as the project root for MCP config:", + placeholder: process.cwd(), + validate(value) { + if (!value?.trim()) return "Path cannot be empty."; + const resolved = resolve(value.trim()); + if (!existsSync(resolved)) + return `Path does not exist: ${resolved}. Please verify and enter a valid path.`; + }, }); - if (p.isCancel(scopeChoice)) { - track("installation:cli_init_cancel", { step: "scope" }); + if (p.isCancel(customPathInput)) { + await track("installation:cli_init_cancel", { step: "scope" }); await finalizeInitTelemetry(); p.cancel("Initialization cancelled."); process.exit(0); } - scope = scopeChoice as "local" | "global" | "custom"; - - if (scope === "custom") { - const customPathInput = await p.text({ - message: "Enter the path to use as the project root for MCP config:", - placeholder: process.cwd(), - validate(value) { - if (!value?.trim()) return "Path cannot be empty."; - const resolved = resolve(value.trim()); - if (!existsSync(resolved)) - return `Path does not exist: ${resolved}. Please verify and enter a valid path.`; - }, - }); - - if (p.isCancel(customPathInput)) { - track("installation:cli_init_cancel", { step: "scope" }); - await finalizeInitTelemetry(); - p.cancel("Initialization cancelled."); - process.exit(0); - } - - customRoot = resolve((customPathInput as string).trim()); - } + customRoot = resolve((customPathInput as string).trim()); } + } - const projectRoot = resolveProjectRoot(process.cwd()); - const effectiveRoot = scope === "custom" ? customRoot! : projectRoot; - const normalizedScope: "local" | "global" = scope === "global" ? "global" : "local"; + const projectRoot = resolveProjectRoot(process.cwd()); + const effectiveRoot = scope === "custom" ? customRoot! : projectRoot; + const normalizedScope: "local" | "global" = scope === "global" ? "global" : "local"; - track("installation:editors_select", { - editors: selectedAdapters.map((a) => sanitizeEditorName(a.name)), - detected_editor_count: detected.length, - scope, - }); + await track("installation:editors_select", { + editors: selectedAdapters.map((a) => sanitizeEditorName(a.name)), + detected_editor_count: detected.length, + scope, + }); - const mcpEntry = getMcpEntry(); - const mcpResults: string[] = []; + const mcpEntry = getMcpEntry(); + const mcpResults: string[] = []; - for (const adapter of selectedAdapters) { - const configPath = - scope === "global" ? adapter.globalPath() : adapter.projectPath(effectiveRoot); + for (const adapter of selectedAdapters) { + const configPath = + scope === "global" ? adapter.globalPath() : adapter.projectPath(effectiveRoot); - if (!configPath) { - if (scope === "global" && adapter.projectPath(projectRoot)) { - const fallback = adapter.projectPath(projectRoot)!; - try { - adapter.write(fallback, mcpEntry); - mcpResults.push( - `${pc.green("+")} ${adapter.name} ${pc.dim(`(local fallback: ${fallback})`)}` - ); - } catch (err) { - mcpResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); - } - } else if (scope !== "global" && adapter.globalPath()) { - const fallback = adapter.globalPath()!; - try { - adapter.write(fallback, mcpEntry); - mcpResults.push( - `${pc.green("+")} ${adapter.name} ${pc.dim(`(global fallback: ${fallback})`)}` - ); - } catch (err) { - mcpResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); - } - } else { + if (!configPath) { + if (scope === "global" && adapter.projectPath(projectRoot)) { + const fallback = adapter.projectPath(projectRoot)!; + try { + adapter.write(fallback, mcpEntry); mcpResults.push( - `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no config path for this scope)")}` + `${pc.green("+")} ${adapter.name} ${pc.dim(`(local fallback: ${fallback})`)}` ); + } catch (err) { + mcpResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); } - continue; - } - - try { - adapter.write(configPath, mcpEntry); - mcpResults.push(`${pc.green("+")} ${adapter.name} ${pc.dim(configPath)}`); - } catch (err) { - mcpResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); + } else if (scope !== "global" && adapter.globalPath()) { + const fallback = adapter.globalPath()!; + try { + adapter.write(fallback, mcpEntry); + mcpResults.push( + `${pc.green("+")} ${adapter.name} ${pc.dim(`(global fallback: ${fallback})`)}` + ); + } catch (err) { + mcpResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); + } + } else { + mcpResults.push( + `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no config path for this scope)")}` + ); } + continue; } - p.note(mcpResults.join("\n"), "MCP Configuration"); + try { + adapter.write(configPath, mcpEntry); + mcpResults.push(`${pc.green("+")} ${adapter.name} ${pc.dim(configPath)}`); + } catch (err) { + mcpResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); + } + } - // ── Tool Auto-Approval ──────────────────────────────────────────────────── + p.note(mcpResults.join("\n"), "MCP Configuration"); - const adaptersWithAllowlist = selectedAdapters.filter((a) => a.addAllowlist); - const adaptersWithoutAllowlist = selectedAdapters.filter((a) => !a.addAllowlist); + // ── Tool Auto-Approval ──────────────────────────────────────────────────── - let allowlistEnabled = false; + const adaptersWithAllowlist = selectedAdapters.filter((a) => a.addAllowlist); + const adaptersWithoutAllowlist = selectedAdapters.filter((a) => !a.addAllowlist); - if (adaptersWithAllowlist.length > 0) { - p.log.info( - `By default, editors ask for confirmation before running each MCP tool.\n` + - ` Adding Argent to the auto-approve allowlist lets tools run without\n` + - ` repeated prompts. This is ${pc.cyan("recommended")} for a smooth experience.` - ); + let allowlistEnabled = false; - if (nonInteractive) { - allowlistEnabled = true; - } else { - p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); + if (adaptersWithAllowlist.length > 0) { + p.log.info( + `By default, editors ask for confirmation before running each MCP tool.\n` + + ` Adding Argent to the auto-approve allowlist lets tools run without\n` + + ` repeated prompts. This is ${pc.cyan("recommended")} for a smooth experience.` + ); - const allowlistChoice = await p.confirm({ - message: "Add Argent tools to editor auto-approve lists? - recommended", - initialValue: true, - }); + if (nonInteractive) { + allowlistEnabled = true; + } else { + p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); - if (p.isCancel(allowlistChoice)) { - track("installation:cli_init_cancel", { step: "allowlist" }); - await finalizeInitTelemetry(); - p.cancel("Initialization cancelled."); - process.exit(0); - } + const allowlistChoice = await p.confirm({ + message: "Add Argent tools to editor auto-approve lists? - recommended", + initialValue: true, + }); - allowlistEnabled = allowlistChoice as boolean; + if (p.isCancel(allowlistChoice)) { + await track("installation:cli_init_cancel", { step: "allowlist" }); + await finalizeInitTelemetry(); + p.cancel("Initialization cancelled."); + process.exit(0); } - } - track("installation:allowlist_decision", { - is_enabled: allowlistEnabled, - }); + allowlistEnabled = allowlistChoice as boolean; + } + } - if (allowlistEnabled) { - const allowlistResults: string[] = []; + await track("installation:allowlist_decision", { + is_enabled: allowlistEnabled, + }); - for (const adapter of adaptersWithAllowlist) { - const hasPath = - normalizedScope === "global" ? adapter.globalPath() : adapter.projectPath(effectiveRoot); - if (!hasPath) { - allowlistResults.push( - `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no config for this scope)")}` - ); - continue; - } - try { - adapter.addAllowlist!(effectiveRoot, normalizedScope); - allowlistResults.push(`${pc.green("+")} ${adapter.name}`); - } catch (err) { - allowlistResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); - } - } + if (allowlistEnabled) { + const allowlistResults: string[] = []; - for (const adapter of adaptersWithoutAllowlist) { + for (const adapter of adaptersWithAllowlist) { + const hasPath = + normalizedScope === "global" ? adapter.globalPath() : adapter.projectPath(effectiveRoot); + if (!hasPath) { allowlistResults.push( - `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no auto-approve API - configure manually)")}` + `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no config for this scope)")}` ); + continue; } + try { + adapter.addAllowlist!(effectiveRoot, normalizedScope); + allowlistResults.push(`${pc.green("+")} ${adapter.name}`); + } catch (err) { + allowlistResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); + } + } - p.note(allowlistResults.join("\n"), "Tool Auto-Approval"); + for (const adapter of adaptersWithoutAllowlist) { + allowlistResults.push( + `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no auto-approve API - configure manually)")}` + ); } - // ── Step 2: Skills Installation ───────────────────────────────────────────── + p.note(allowlistResults.join("\n"), "Tool Auto-Approval"); + } - p.log.step(pc.bold("Step 2: Skills Installation")); - p.log.warn(pc.yellow("Skills installation is required for Argent to function properly.")); + // ── Step 2: Skills Installation ───────────────────────────────────────────── - type SkillsMethod = "default" | "interactive" | "manual"; - let skillsMethod: SkillsMethod; + p.log.step(pc.bold("Step 2: Skills Installation")); + p.log.warn(pc.yellow("Skills installation is required for Argent to function properly.")); - const online = await isOnline(); - const offlineWithCache = !online && isSkillsCliAvailable(); - const skillsCliReady = online || offlineWithCache; + type SkillsMethod = "default" | "interactive" | "manual"; + let skillsMethod: SkillsMethod; - if (!skillsCliReady) { - p.log.warn( - pc.yellow("You appear to be offline. ") + - "Automatic skills installation requires a network connection." - ); + const online = await isOnline(); + const offlineWithCache = !online && isSkillsCliAvailable(); + const skillsCliReady = online || offlineWithCache; + + if (!skillsCliReady) { + p.log.warn( + pc.yellow("You appear to be offline. ") + + "Automatic skills installation requires a network connection." + ); + } + + if (!skillsCliReady) { + skillsMethod = "manual"; + } else if (nonInteractive) { + skillsMethod = "default"; + } else { + p.log.message(pc.dim(" Use arrow keys to move, enter to confirm.")); + + const choice = await p.select({ + message: "How would you like to install skills?", + options: [ + { + value: "default" as const, + label: "Automatic", + hint: "Installs all skills automatically with npx skills", + }, + { + value: "interactive" as const, + label: "Interactive", + hint: "Full npx skills TUI - choose skills, agents, and method", + }, + { + value: "manual" as const, + label: "Manual", + hint: "Print instructions for manual installation", + }, + ], + }); + + if (p.isCancel(choice)) { + await track("installation:cli_init_cancel", { step: "skills" }); + await finalizeInitTelemetry(); + p.cancel("Initialization cancelled."); + process.exit(0); } - if (!skillsCliReady) { - skillsMethod = "manual"; - } else if (nonInteractive) { - skillsMethod = "default"; - } else { - p.log.message(pc.dim(" Use arrow keys to move, enter to confirm.")); + skillsMethod = choice as SkillsMethod; + } - const choice = await p.select({ - message: "How would you like to install skills?", - options: [ - { - value: "default" as const, - label: "Automatic", - hint: "Installs all skills automatically with npx skills", - }, - { - value: "interactive" as const, - label: "Interactive", - hint: "Full npx skills TUI - choose skills, agents, and method", - }, - { - value: "manual" as const, - label: "Manual", - hint: "Print instructions for manual installation", - }, - ], - }); + // Prefer the GitHub-pinned source. SKILLS_DIR as a fallback. + const useGitHubSource = online && !fromTar && version !== "unknown"; + const skillsSource = useGitHubSource ? buildArgentSkillsSource(version) : SKILLS_DIR; - if (p.isCancel(choice)) { - track("installation:cli_init_cancel", { step: "skills" }); - await finalizeInitTelemetry(); - p.cancel("Initialization cancelled."); - process.exit(0); - } + let skillOutcome: "success" | "failure" | "skipped"; + + if (skillsMethod === "manual") { + p.note( + [ + `Skills are bundled at:`, + ` ${pc.cyan(SKILLS_DIR)}`, + ``, + `To install manually, copy them to your editor's skills directory:`, + ``, + ` ${pc.dim("# Claude Code")}`, + ` cp -r ${SKILLS_DIR}/* ${scope === "global" ? "~/.claude/skills/" : `${scope === "custom" ? customRoot! : "."}/.claude/skills/`}`, + ``, + ` ${pc.dim("# Cursor")}`, + ` cp -r ${SKILLS_DIR}/* ${scope === "global" ? "~/.cursor/skills/" : `${scope === "custom" ? customRoot! : "."}/.cursor/skills/`}`, + ``, + ` ${pc.dim("# Or use npx skills directly:")}`, + ` npx skills add ${skillsSource}`, + ].join("\n"), + "Manual Skills Installation" + ); + skillOutcome = "skipped"; + } else { + const skillsArgs = ["skills", "add", skillsSource]; - skillsMethod = choice as SkillsMethod; + if (scope === "global") { + skillsArgs.push("-g"); } - // Prefer the GitHub-pinned source. SKILLS_DIR as a fallback. - const useGitHubSource = online && !fromTar && version !== "unknown"; - const skillsSource = useGitHubSource ? buildArgentSkillsSource(version) : SKILLS_DIR; - - let skillOutcome: "success" | "failure" | "skipped"; - - if (skillsMethod === "manual") { - p.note( - [ - `Skills are bundled at:`, - ` ${pc.cyan(SKILLS_DIR)}`, - ``, - `To install manually, copy them to your editor's skills directory:`, - ``, - ` ${pc.dim("# Claude Code")}`, - ` cp -r ${SKILLS_DIR}/* ${scope === "global" ? "~/.claude/skills/" : `${scope === "custom" ? customRoot! : "."}/.claude/skills/`}`, - ``, - ` ${pc.dim("# Cursor")}`, - ` cp -r ${SKILLS_DIR}/* ${scope === "global" ? "~/.cursor/skills/" : `${scope === "custom" ? customRoot! : "."}/.cursor/skills/`}`, - ``, - ` ${pc.dim("# Or use npx skills directly:")}`, - ` npx skills add ${skillsSource}`, - ].join("\n"), - "Manual Skills Installation" - ); - skillOutcome = "skipped"; - } else { - const skillsArgs = ["skills", "add", skillsSource]; + if (skillsMethod === "default") { + skillsArgs.push("--skill", "*", "-y"); + } - if (scope === "global") { - skillsArgs.push("-g"); - } + const npxArgs = offlineWithCache ? ["--no-install", ...skillsArgs] : skillsArgs; - if (skillsMethod === "default") { - skillsArgs.push("--skill", "*", "-y"); - } + p.log.info(`Running: ${pc.dim("npx")} ${pc.cyan(npxArgs.join(" "))}`); - const npxArgs = offlineWithCache ? ["--no-install", ...skillsArgs] : skillsArgs; - - p.log.info(`Running: ${pc.dim("npx")} ${pc.cyan(npxArgs.join(" "))}`); + const spinner = p.spinner(); + if (skillsMethod === "default") { + spinner.start("Installing skills..."); + } - const spinner = p.spinner(); + try { + const skillsCwd = scope === "custom" ? customRoot : undefined; + await runNpxSkills(npxArgs, skillsMethod === "interactive", skillsCwd); if (skillsMethod === "default") { - spinner.start("Installing skills..."); + spinner.stop("Skills installed."); } - - try { - const skillsCwd = scope === "custom" ? customRoot : undefined; - await runNpxSkills(npxArgs, skillsMethod === "interactive", skillsCwd); - if (skillsMethod === "default") { - spinner.stop("Skills installed."); - } - skillOutcome = "success"; - } catch (err) { - if (skillsMethod === "default") { - spinner.stop(pc.red("Skills installation failed.")); - } - p.log.error(`Failed to run npx skills: ${err}`); - p.log.info(`You can install skills manually:\n npx ${skillsArgs.join(" ")}`); - skillOutcome = "failure"; + skillOutcome = "success"; + } catch (err) { + if (skillsMethod === "default") { + spinner.stop(pc.red("Skills installation failed.")); } + p.log.error(`Failed to run npx skills: ${err}`); + p.log.info(`You can install skills manually:\n npx ${skillsArgs.join(" ")}`); + skillOutcome = "failure"; } + } - track("installation:skill_install", { - method: skillsMethod, - is_online: online, - has_offline_cache: offlineWithCache, - outcome: skillOutcome, - }); + await track("installation:skill_install", { + method: skillsMethod, + is_online: online, + has_offline_cache: offlineWithCache, + outcome: skillOutcome, + }); - // ── Step 3: Rules and Agents ──────────────────────────────────────────────── + // ── Step 3: Rules and Agents ──────────────────────────────────────────────── - p.log.step(pc.bold("Step 3: Rules & Agents")); + p.log.step(pc.bold("Step 3: Rules & Agents")); - const copyResults = copyRulesAndAgents( - selectedAdapters, - effectiveRoot, - normalizedScope, - RULES_DIR, - AGENTS_DIR - ); + const copyResults = copyRulesAndAgents( + selectedAdapters, + effectiveRoot, + normalizedScope, + RULES_DIR, + AGENTS_DIR + ); - if (copyResults.length > 0) { - p.note(copyResults.join("\n"), "Rules & Agents"); - } else { - p.log.info(pc.dim("No rules or agents to copy for selected editors.")); - } + if (copyResults.length > 0) { + p.note(copyResults.join("\n"), "Rules & Agents"); + } else { + p.log.info(pc.dim("No rules or agents to copy for selected editors.")); + } - // ── Summary ───────────────────────────────────────────────────────────────── + // ── Summary ───────────────────────────────────────────────────────────────── - const summaryLines = [ - `${pc.green("MCP server")} configured for ${selectedAdapters.map((a) => a.name).join(", ")} (${scope})`, - `${pc.green("Auto-approve")} ${allowlistEnabled ? "enabled" : "skipped"}`, - `${pc.green("Skills")} ${skillsMethod === "manual" ? "instructions printed" : "installed"}`, - `${pc.green("Rules & agents")} ${copyResults.length > 0 ? "copied" : "n/a"}`, - ]; + const summaryLines = [ + `${pc.green("MCP server")} configured for ${selectedAdapters.map((a) => a.name).join(", ")} (${scope})`, + `${pc.green("Auto-approve")} ${allowlistEnabled ? "enabled" : "skipped"}`, + `${pc.green("Skills")} ${skillsMethod === "manual" ? "instructions printed" : "installed"}`, + `${pc.green("Rules & agents")} ${copyResults.length > 0 ? "copied" : "n/a"}`, + ]; - p.note(summaryLines.join("\n"), "Summary"); + p.note(summaryLines.join("\n"), "Summary"); - p.note( - [ - pc.bold(pc.green("Argent is ready!")), - "", - `${pc.bold("Get started")} by asking your assistant:`, - "", - ` ${pc.bold(pc.cyan(`"What can Argent do?"`))}`, - "", - pc.dim("It will walk you through all capabilities available."), - ].join("\n"), - pc.bgGreen(pc.black(" Get Started ")) - ); - p.outro("Done."); + p.note( + [ + pc.bold(pc.green("Argent is ready!")), + "", + `${pc.bold("Get started")} by asking your assistant:`, + "", + ` ${pc.bold(pc.cyan(`"What can Argent do?"`))}`, + "", + pc.dim("It will walk you through all capabilities available."), + ].join("\n"), + pc.bgGreen(pc.black(" Get Started ")) + ); + p.outro("Done."); - initSucceeded = true; - await finalizeInitTelemetry(); - } catch (err) { - await finalizeInitTelemetry(); - throw err; - } + initSucceeded = true; + await finalizeInitTelemetry(); } // Print the notice only when this run may send telemetry. diff --git a/packages/argent-installer/src/skills.ts b/packages/argent-installer/src/skills.ts index 5da9a473..dbe33e1c 100644 --- a/packages/argent-installer/src/skills.ts +++ b/packages/argent-installer/src/skills.ts @@ -24,6 +24,13 @@ export interface SkillScopeResult { pruneError: string | null; } +export interface SkillRefreshTelemetrySummary { + scope_count: number; + synced_count: number; + pruned_count: number; + failed_count: number; +} + interface ScopeSpec { scope: SkillScope; lockPath: string; @@ -150,3 +157,14 @@ export function formatSkillRefreshSummary(results: readonly SkillScopeResult[]): } return lines.length > 0 ? lines.join("\n") : null; } + +export function summarizeSkillRefreshForTelemetry( + results: readonly SkillScopeResult[] +): SkillRefreshTelemetrySummary { + return { + scope_count: results.length, + synced_count: results.reduce((sum, result) => sum + result.synced, 0), + pruned_count: results.reduce((sum, result) => sum + result.pruned.length, 0), + failed_count: results.filter((result) => result.syncError || result.pruneError).length, + }; +} diff --git a/packages/argent-installer/src/uninstall.ts b/packages/argent-installer/src/uninstall.ts index f787d981..16892117 100644 --- a/packages/argent-installer/src/uninstall.ts +++ b/packages/argent-installer/src/uninstall.ts @@ -4,6 +4,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { execFileSync } from "node:child_process"; import { init as telemetryInit, track, forget as telemetryForget } from "@argent/telemetry"; +import { FAILURE_CODES, type FailureSignal } from "@argent/registry"; import { ALL_ADAPTERS, getManagedContentTargets, @@ -24,6 +25,22 @@ import { PACKAGE_NAME } from "./constants.js"; import { killToolServer } from "@argent/tools-client"; import { finalizeTelemetry } from "./telemetry-finalize.js"; +type InstallerFailureSignal = FailureSignal & { failure_area: "installer" }; + +const UNINSTALL_TOOLSERVER_STOP_FAILED: InstallerFailureSignal = { + error_code: FAILURE_CODES.UNINSTALL_TOOLSERVER_STOP_FAILED, + failure_stage: "installer_uninstall_toolserver_stop", + failure_area: "installer", + error_kind: "subprocess", +}; + +const UNINSTALL_PACKAGE_ACTION_FAILED: InstallerFailureSignal = { + error_code: FAILURE_CODES.UNINSTALL_PACKAGE_ACTION_FAILED, + failure_stage: "installer_uninstall_package_action", + failure_area: "installer", + error_kind: "subprocess", +}; + export interface BundledContentRemoval { removedPaths: string[]; removedRoot: boolean; @@ -289,12 +306,13 @@ export async function uninstall(args: string[]): Promise { const nonInteractive = args.includes("--yes") || args.includes("-y"); telemetryInit("installer"); - track("installation:cli_uninstall_start", {}); - let telemetryFinalized = false; + await track("installation:cli_uninstall_start", {}); + let telemetryFinalized = false; const finalizeUninstallTelemetry = async ( hasPrunedContent: boolean, - hasUninstalledPackage: boolean + hasUninstalledPackage: boolean, + failureSignal?: InstallerFailureSignal ): Promise => { if (telemetryFinalized) return; telemetryFinalized = true; @@ -302,219 +320,217 @@ export async function uninstall(args: string[]): Promise { track("installation:cli_uninstall_complete", { has_pruned_content: hasPrunedContent, has_uninstalled_package: hasUninstalledPackage, + ...(failureSignal ?? {}), }); }); }; - let shouldPrune = nonInteractive; - let hasUninstalledPackage = false; - - try { - p.intro(pc.bgRed(pc.white(" argent uninstall "))); + p.intro(pc.bgRed(pc.white(" argent uninstall "))); - if (!nonInteractive) { - p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); + if (!nonInteractive) { + p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); - const proceed = await p.confirm({ - message: "Remove argent configuration from this workspace?", - initialValue: true, - }); + const proceed = await p.confirm({ + message: "Remove argent configuration from this workspace?", + initialValue: true, + }); - if (p.isCancel(proceed) || !proceed) { - await finalizeUninstallTelemetry(false, false); - p.cancel("Uninstall cancelled."); - process.exit(0); - } + if (p.isCancel(proceed) || !proceed) { + await finalizeUninstallTelemetry(false, false); + p.cancel("Uninstall cancelled."); + process.exit(0); } + } - const projectRoot = resolveProjectRoot(process.cwd()); - const results: string[] = []; + const projectRoot = resolveProjectRoot(process.cwd()); + const results: string[] = []; - // ── Remove MCP entries ────────────────────────────────────────────────────── + // ── Remove MCP entries ────────────────────────────────────────────────────── - p.log.step(pc.bold("Removing MCP server entries...")); + p.log.step(pc.bold("Removing MCP server entries...")); - for (const adapter of ALL_ADAPTERS) { - for (const pathFn of [() => adapter.projectPath(projectRoot), () => adapter.globalPath()]) { - const configPath = pathFn(); - if (!configPath) continue; - try { - const removed = adapter.remove(configPath); - if (removed) { - results.push(`${pc.green("+")} Removed from ${adapter.name} ${pc.dim(configPath)}`); - } - } catch { - // non-fatal + for (const adapter of ALL_ADAPTERS) { + for (const pathFn of [() => adapter.projectPath(projectRoot), () => adapter.globalPath()]) { + const configPath = pathFn(); + if (!configPath) continue; + try { + const removed = adapter.remove(configPath); + if (removed) { + results.push(`${pc.green("+")} Removed from ${adapter.name} ${pc.dim(configPath)}`); } + } catch { + // non-fatal } } + } - // ── Remove allowlists ────────────────────────────────────────────────────── + // ── Remove allowlists ────────────────────────────────────────────────────── - for (const adapter of ALL_ADAPTERS) { - if (!adapter.removeAllowlist) continue; - for (const s of ["local", "global"] as const) { - try { - adapter.removeAllowlist(projectRoot, s); - results.push(`${pc.green("+")} Removed ${adapter.name} allowlist ${pc.dim(`(${s})`)}`); - } catch { - // non-fatal - } + for (const adapter of ALL_ADAPTERS) { + if (!adapter.removeAllowlist) continue; + for (const s of ["local", "global"] as const) { + try { + adapter.removeAllowlist(projectRoot, s); + results.push(`${pc.green("+")} Removed ${adapter.name} allowlist ${pc.dim(`(${s})`)}`); + } catch { + // non-fatal } } + } - if (results.length > 0) { - p.note(results.join("\n"), "MCP Entries Removed"); - } else { - p.log.info(pc.dim("No MCP entries found to remove.")); - } + if (results.length > 0) { + p.note(results.join("\n"), "MCP Entries Removed"); + } else { + p.log.info(pc.dim("No MCP entries found to remove.")); + } - // ── Prune skills / rules / agents ─────────────────────────────────────────── + // ── Prune skills / rules / agents ─────────────────────────────────────────── - if (!nonInteractive) { - p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); + let shouldPrune = nonInteractive; - const pruneChoice = await p.confirm({ - message: "Also remove Argent-owned skills, rules, and agents?", - initialValue: true, - }); + if (!nonInteractive) { + p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); - if (!p.isCancel(pruneChoice)) { - shouldPrune = pruneChoice as boolean; - } - } + const pruneChoice = await p.confirm({ + message: "Also remove Argent-owned skills, rules, and agents?", + initialValue: true, + }); - if (shouldPrune) { - const pruneResults: string[] = []; - const localTargets = getManagedContentTargets(ALL_ADAPTERS, projectRoot, "local"); - const globalTargets = getManagedContentTargets(ALL_ADAPTERS, projectRoot, "global"); - - const bundledSkillNames = getBundledSkillNames(SKILLS_DIR); - pruneResults.push( - ...cleanupBundledSkills(bundledSkillNames, [ - ...localTargets.skillTargets, - ...globalTargets.skillTargets, - ]) - ); + if (!p.isCancel(pruneChoice)) { + shouldPrune = pruneChoice as boolean; + } + } - for (const { targetPath, label } of [ - ...localTargets.skillsLockTargets, - ...globalTargets.skillsLockTargets, - ]) { - try { - const { removedSkills, removedFile } = cleanupSkillsLockFile( - targetPath, - bundledSkillNames - ); - if (removedSkills.length === 0 && !removedFile) continue; - - const itemsLabel = removedSkills.length === 1 ? "skill" : "skills"; - const fileLabel = removedFile ? " and removed the now-empty lockfile" : ""; - pruneResults.push( - `${pc.green("+")} Removed ${removedSkills.length} Argent ${itemsLabel} from ${label}${fileLabel}` - ); - } catch (err) { - pruneResults.push(`${pc.red("x")} Could not clean ${label}: ${err}`); - } + if (shouldPrune) { + const pruneResults: string[] = []; + const localTargets = getManagedContentTargets(ALL_ADAPTERS, projectRoot, "local"); + const globalTargets = getManagedContentTargets(ALL_ADAPTERS, projectRoot, "global"); + + const bundledSkillNames = getBundledSkillNames(SKILLS_DIR); + pruneResults.push( + ...cleanupBundledSkills(bundledSkillNames, [ + ...localTargets.skillTargets, + ...globalTargets.skillTargets, + ]) + ); + + for (const { targetPath, label } of [ + ...localTargets.skillsLockTargets, + ...globalTargets.skillsLockTargets, + ]) { + try { + const { removedSkills, removedFile } = cleanupSkillsLockFile(targetPath, bundledSkillNames); + if (removedSkills.length === 0 && !removedFile) continue; + + const itemsLabel = removedSkills.length === 1 ? "skill" : "skills"; + const fileLabel = removedFile ? " and removed the now-empty lockfile" : ""; + pruneResults.push( + `${pc.green("+")} Removed ${removedSkills.length} Argent ${itemsLabel} from ${label}${fileLabel}` + ); + } catch (err) { + pruneResults.push(`${pc.red("x")} Could not clean ${label}: ${err}`); } + } - const bundledTargets: Array<{ - sourceDir: string; - targets: ManagedContentTarget[]; - contentLabel: string; - }> = [ - { - sourceDir: AGENTS_DIR, - targets: [...localTargets.agentTargets, ...globalTargets.agentTargets], - contentLabel: "agent", - }, - { - sourceDir: RULES_DIR, - targets: [...localTargets.ruleTargets, ...globalTargets.ruleTargets], - contentLabel: "rule", - }, - ]; - - for (const { sourceDir, targets, contentLabel } of bundledTargets) { - try { - pruneResults.push(...cleanupBundledTargets(sourceDir, targets, contentLabel)); - } catch { - // non-fatal - } + const bundledTargets: Array<{ + sourceDir: string; + targets: ManagedContentTarget[]; + contentLabel: string; + }> = [ + { + sourceDir: AGENTS_DIR, + targets: [...localTargets.agentTargets, ...globalTargets.agentTargets], + contentLabel: "agent", + }, + { + sourceDir: RULES_DIR, + targets: [...localTargets.ruleTargets, ...globalTargets.ruleTargets], + contentLabel: "rule", + }, + ]; + + for (const { sourceDir, targets, contentLabel } of bundledTargets) { + try { + pruneResults.push(...cleanupBundledTargets(sourceDir, targets, contentLabel)); + } catch { + // non-fatal } + } - // Codex: remove argent rules from developer_instructions in config.toml - for (const { targetPath, label } of [ - ...localTargets.codexConfigTargets, - ...globalTargets.codexConfigTargets, - ]) { - try { - if (removeCodexRules(targetPath)) { - pruneResults.push(`${pc.green("+")} Removed argent rules from ${label}`); - } - } catch (err) { - pruneResults.push(`${pc.red("x")} Could not clean ${label}: ${err}`); + // Codex: remove argent rules from developer_instructions in config.toml + for (const { targetPath, label } of [ + ...localTargets.codexConfigTargets, + ...globalTargets.codexConfigTargets, + ]) { + try { + if (removeCodexRules(targetPath)) { + pruneResults.push(`${pc.green("+")} Removed argent rules from ${label}`); } + } catch (err) { + pruneResults.push(`${pc.red("x")} Could not clean ${label}: ${err}`); } + } - if (pruneResults.length > 0) { - p.note(pruneResults.join("\n"), "Pruned Argent Content"); - } else { - p.log.info(pc.dim("No Argent-owned skills, rules, or agents found to remove.")); - } + if (pruneResults.length > 0) { + p.note(pruneResults.join("\n"), "Pruned Argent Content"); } else { - p.log.info(pc.dim("Kept Argent-owned skills, rules, and agents.")); + p.log.info(pc.dim("No Argent-owned skills, rules, or agents found to remove.")); } + } else { + p.log.info(pc.dim("Kept Argent-owned skills, rules, and agents.")); + } - // ── Uninstall the global package ──────────────────────────────────────────── + // ── Uninstall the global package ──────────────────────────────────────────── - const globallyInstalled = isGloballyInstalled(); - let shouldUninstallPackage = nonInteractive && globallyInstalled; + const globallyInstalled = isGloballyInstalled(); + let shouldUninstallPackage = nonInteractive && globallyInstalled; - if (!globallyInstalled) { - p.log.info(pc.dim(`${PACKAGE_NAME} is not installed globally; skipping package uninstall.`)); - } else if (!nonInteractive) { - p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); + if (!nonInteractive && globallyInstalled) { + p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); - const uninstallPkg = await p.confirm({ - message: `Uninstall the global ${PACKAGE_NAME} package?`, - initialValue: false, - }); + const uninstallPkg = await p.confirm({ + message: `Uninstall the global ${PACKAGE_NAME} package?`, + initialValue: false, + }); - if (!p.isCancel(uninstallPkg)) { - shouldUninstallPackage = uninstallPkg as boolean; - } + if (!p.isCancel(uninstallPkg)) { + shouldUninstallPackage = uninstallPkg as boolean; } + } - if (shouldUninstallPackage) { - const pm = detectPackageManager(); - const cmd = globalUninstallCommand(pm, PACKAGE_NAME); - p.log.info(`Running: ${pc.dim(formatShellCommand(cmd))}`); + let hasUninstalledPackage = false; + if (shouldUninstallPackage) { + const pm = detectPackageManager(); + const cmd = globalUninstallCommand(pm, PACKAGE_NAME); + p.log.info(`Running: ${pc.dim(formatShellCommand(cmd))}`); + try { await killToolServer(); - - try { - execFileSync(cmd.bin, cmd.args, { stdio: "inherit" }); - p.log.success("Package uninstalled."); - hasUninstalledPackage = true; - } catch (err) { - p.log.error(`Uninstall failed: ${err}`); - } + } catch (err) { + p.log.error(`Could not stop the running tool server: ${err}`); + await finalizeUninstallTelemetry(shouldPrune, false, UNINSTALL_TOOLSERVER_STOP_FAILED); + throw err; } - await finalizeUninstallTelemetry(shouldPrune, hasUninstalledPackage); - - if (hasUninstalledPackage) { - try { - await telemetryForget({ disableConsent: false }); - } catch { - /* swallow — uninstall must succeed even if forget fails */ - } + try { + execFileSync(cmd.bin, cmd.args, { stdio: "inherit" }); + p.log.success("Package uninstalled."); + hasUninstalledPackage = true; + } catch (err) { + p.log.error(`Uninstall failed: ${err}`); + await finalizeUninstallTelemetry(shouldPrune, false, UNINSTALL_PACKAGE_ACTION_FAILED); + return; } + } - p.outro(pc.green("argent has been removed.")); - } catch (err) { - await finalizeUninstallTelemetry(shouldPrune, hasUninstalledPackage); - throw err; + await finalizeUninstallTelemetry(shouldPrune, hasUninstalledPackage); + if (hasUninstalledPackage) { + try { + await telemetryForget({ disableConsent: false }); + } catch { + /* swallow — uninstall must succeed even if forget fails */ + } } + + p.outro(pc.green("argent has been removed.")); } diff --git a/packages/argent-installer/src/update.ts b/packages/argent-installer/src/update.ts index dfb6467b..586f9fb2 100644 --- a/packages/argent-installer/src/update.ts +++ b/packages/argent-installer/src/update.ts @@ -3,6 +3,7 @@ import pc from "picocolors"; import { execFileSync } from "node:child_process"; import semver from "semver"; import { init as telemetryInit, track } from "@argent/telemetry"; +import { FAILURE_CODES, type FailureSignal } from "@argent/registry"; import { ALL_ADAPTERS, findConfiguredAdapterScopes, @@ -21,7 +22,11 @@ import { RULES_DIR, AGENTS_DIR, } from "./utils.js"; -import { refreshArgentSkills, formatSkillRefreshSummary } from "./skills.js"; +import { + refreshArgentSkills, + formatSkillRefreshSummary, + summarizeSkillRefreshForTelemetry, +} from "./skills.js"; import { PACKAGE_NAME } from "./constants.js"; import { resolveInstallableUpdateTarget } from "./update-target.js"; import { killToolServer } from "@argent/tools-client"; @@ -43,6 +48,50 @@ function getRequestedVersion(args: string[]): string | null { type UpdateTrigger = "update" | "mcp_update"; type UpdatePackageAction = "standalone_update" | "standalone_install" | "mcp_update"; +type InstallerFailureSignal = FailureSignal & { failure_area: "installer" }; + +const UPDATE_INSTALLED_VERSION_DETECT_FAILED: InstallerFailureSignal = { + error_code: FAILURE_CODES.UPDATE_INSTALLED_VERSION_DETECT_FAILED, + failure_stage: "installer_update_installed_version_detect", + failure_area: "installer", + error_kind: "unknown", +}; + +const UPDATE_REGISTRY_CHECK_FAILED: InstallerFailureSignal = { + error_code: FAILURE_CODES.UPDATE_REGISTRY_CHECK_FAILED, + failure_stage: "installer_update_registry_check", + failure_area: "installer", + error_kind: "network", +}; + +const UPDATE_TOOLSERVER_STOP_FAILED: InstallerFailureSignal = { + error_code: FAILURE_CODES.UPDATE_TOOLSERVER_STOP_FAILED, + failure_stage: "installer_update_toolserver_stop", + failure_area: "installer", + error_kind: "subprocess", +}; + +const UPDATE_PACKAGE_ACTION_FAILED: InstallerFailureSignal = { + error_code: FAILURE_CODES.UPDATE_PACKAGE_ACTION_FAILED, + failure_stage: "installer_update_package_action", + failure_area: "installer", + error_kind: "subprocess", +}; + +const UPDATE_UNCLASSIFIED_FAILED: InstallerFailureSignal = { + error_code: FAILURE_CODES.UPDATE_UNCLASSIFIED_FAILED, + failure_stage: "installer_update_unclassified", + failure_area: "installer", + error_kind: "unknown", +}; + +const INSTALL_SKILLS_REFRESH_FAILED: InstallerFailureSignal = { + error_code: FAILURE_CODES.INSTALL_SKILLS_REFRESH_FAILED, + failure_stage: "installer_update_skills_refresh", + failure_area: "installer", + error_kind: "subprocess", +}; + export function getUpdateTriggerFromEnv(env: NodeJS.ProcessEnv = process.env): UpdateTrigger { return env.ARGENT_UPDATE_TRIGGER === "mcp_update" ? "mcp_update" : "update"; } @@ -61,28 +110,31 @@ export async function update(args: string[]): Promise { const trigger = getUpdateTriggerFromEnv(); telemetryInit("installer"); const updateStartTime = performance.now(); - track("installation:cli_update_start", {}); + await track("installation:cli_update_start", {}); let telemetryFinalized = false; const trackPackageAction = async ( action: UpdatePackageAction | "no_update" | "update_skipped" | "update_failed", startedAt: number, - isSuccess: boolean + isSuccess: boolean, + failureSignal?: InstallerFailureSignal ): Promise => { - track("installation:package_action", { + await track("installation:package_action", { trigger, action, is_success: isSuccess, duration_ms: performance.now() - startedAt, + ...(failureSignal ?? {}), }); }; - const failUpdateTelemetry = async (): Promise => { + const failUpdateTelemetry = async (failureSignal?: InstallerFailureSignal): Promise => { if (telemetryFinalized) return; telemetryFinalized = true; await finalizeTelemetry(() => { track("installation:cli_update_fail", { duration_ms: performance.now() - updateStartTime, + ...(failureSignal ?? {}), }); }); }; @@ -111,8 +163,13 @@ export async function update(args: string[]): Promise { const installed = globallyInstalled ? getGloballyInstalledVersion() : null; if (globallyInstalled && !installed) { - await trackPackageAction("update_failed", updateStartTime, false); - await failUpdateTelemetry(); + await trackPackageAction( + "update_failed", + updateStartTime, + false, + UPDATE_INSTALLED_VERSION_DETECT_FAILED + ); + await failUpdateTelemetry(UPDATE_INSTALLED_VERSION_DETECT_FAILED); p.log.error("Could not determine installed version."); process.exit(1); } @@ -128,8 +185,13 @@ export async function update(args: string[]): Promise { if (requestedVersion !== null) { if (!semver.valid(requestedVersion) || semver.prerelease(requestedVersion)) { spinner.stop(pc.red("Invalid update target.")); - await trackPackageAction("update_failed", updateStartTime, false); - await failUpdateTelemetry(); + await trackPackageAction( + "update_failed", + updateStartTime, + false, + UPDATE_REGISTRY_CHECK_FAILED + ); + await failUpdateTelemetry(UPDATE_REGISTRY_CHECK_FAILED); p.log.error(`Requested version is not a stable semver: ${requestedVersion}`); process.exit(1); } @@ -140,16 +202,26 @@ export async function update(args: string[]): Promise { resolved = await resolveInstallableUpdateTarget(pm, installed); } catch (err) { spinner.stop(pc.red("Could not reach registry.")); - await trackPackageAction("update_failed", updateStartTime, false); - await failUpdateTelemetry(); + await trackPackageAction( + "update_failed", + updateStartTime, + false, + UPDATE_REGISTRY_CHECK_FAILED + ); + await failUpdateTelemetry(UPDATE_REGISTRY_CHECK_FAILED); p.log.error(`Failed to check registry: ${err}`); process.exit(1); } if (resolved === null) { spinner.stop(pc.red("Could not reach registry.")); - await trackPackageAction("update_failed", updateStartTime, false); - await failUpdateTelemetry(); + await trackPackageAction( + "update_failed", + updateStartTime, + false, + UPDATE_REGISTRY_CHECK_FAILED + ); + await failUpdateTelemetry(UPDATE_REGISTRY_CHECK_FAILED); p.log.error("Failed to determine the latest Argent release from the registry."); process.exit(1); } @@ -209,8 +281,13 @@ export async function update(args: string[]): Promise { try { await killToolServer(); } catch (err) { - await trackPackageAction("update_failed", updateStartTime, false); - await failUpdateTelemetry(); + await trackPackageAction( + "update_failed", + updateStartTime, + false, + UPDATE_TOOLSERVER_STOP_FAILED + ); + await failUpdateTelemetry(UPDATE_TOOLSERVER_STOP_FAILED); p.log.error(`Could not stop the running tool server: ${err}`); process.exit(1); } @@ -223,8 +300,13 @@ export async function update(args: string[]): Promise { env: { ...process.env, ARGENT_SKIP_POSTINSTALL: "1" }, }); } catch (err) { - await trackPackageAction(packageAction, packageActionStartedAt, false); - await failUpdateTelemetry(); + await trackPackageAction( + packageAction, + packageActionStartedAt, + false, + UPDATE_PACKAGE_ACTION_FAILED + ); + await failUpdateTelemetry(UPDATE_PACKAGE_ACTION_FAILED); p.log.error(`${installed ? "Update" : "Install"} failed: ${err}`); process.exit(1); } @@ -301,16 +383,25 @@ export async function update(args: string[]): Promise { p.note(ruleResults.join("\n"), "Rules & Agents Updated"); } - const skillSummary = formatSkillRefreshSummary(refreshArgentSkills(projectRoot)); + const skillRefreshResults = refreshArgentSkills(projectRoot); + const skillSummary = formatSkillRefreshSummary(skillRefreshResults); if (skillSummary) { p.note(skillSummary, "Skills Updated"); } + const skillTelemetrySummary = summarizeSkillRefreshForTelemetry(skillRefreshResults); + if (skillTelemetrySummary.scope_count > 0) { + await track("installation:skill_refresh_result", { + is_success: skillTelemetrySummary.failed_count === 0, + ...skillTelemetrySummary, + ...(skillTelemetrySummary.failed_count > 0 ? INSTALL_SKILLS_REFRESH_FAILED : {}), + }); + } await completeUpdateTelemetry(); p.outro(pc.green("Update complete.")); } catch (err) { - await failUpdateTelemetry(); + await failUpdateTelemetry(UPDATE_UNCLASSIFIED_FAILED); throw err; } } diff --git a/packages/argent-installer/test/uninstall.test.ts b/packages/argent-installer/test/uninstall.test.ts index a8adc299..e335737f 100644 --- a/packages/argent-installer/test/uninstall.test.ts +++ b/packages/argent-installer/test/uninstall.test.ts @@ -138,10 +138,14 @@ describe("uninstall — telemetry consent preservation", () => { await uninstall(["--yes"]); - expect(telemetryMock.track).toHaveBeenCalledWith("installation:cli_uninstall_complete", { - has_pruned_content: true, - has_uninstalled_package: false, - }); + expect(telemetryMock.track).toHaveBeenCalledWith( + "installation:cli_uninstall_complete", + expect.objectContaining({ + error_code: "UNINSTALL_PACKAGE_ACTION_FAILED", + has_pruned_content: true, + has_uninstalled_package: false, + }) + ); expect(telemetryMock.forget).not.toHaveBeenCalled(); }); @@ -151,10 +155,14 @@ describe("uninstall — telemetry consent preservation", () => { await expect(uninstall(["--yes"])).rejects.toThrow("tool server busy"); - expect(telemetryMock.track).toHaveBeenCalledWith("installation:cli_uninstall_complete", { - has_pruned_content: true, - has_uninstalled_package: false, - }); + expect(telemetryMock.track).toHaveBeenCalledWith( + "installation:cli_uninstall_complete", + expect.objectContaining({ + error_code: "UNINSTALL_TOOLSERVER_STOP_FAILED", + has_pruned_content: true, + has_uninstalled_package: false, + }) + ); expect(telemetryMock.shutdown).toHaveBeenCalledOnce(); expect(telemetryMock.forget).not.toHaveBeenCalled(); }); diff --git a/packages/registry/src/errors.ts b/packages/registry/src/errors.ts index f0cdf85b..c9b9b6cb 100644 --- a/packages/registry/src/errors.ts +++ b/packages/registry/src/errors.ts @@ -1,11 +1,204 @@ // ── Service Errors ── +import { FAILURE_CODES, type FailureCode } from "./failure-codes"; + +export const FAILURE_AREAS = ["cli", "http", "registry", "tool_server", "installer"] as const; + +export type FailureArea = (typeof FAILURE_AREAS)[number]; + +export const FAILURE_KINDS = [ + "validation", + "not_found", + "dependency_missing", + "unsupported", + "not_implemented", + "timeout", + "network", + "subprocess", + "crash", + "unknown", +] as const; + +export type FailureKind = (typeof FAILURE_KINDS)[number]; + +export const FAILURE_COMMANDS = [ + "adb", + "emulator", + "xcrun_simctl", + "xctrace", + "native_devtools", + "android_devtools", + "ax_service", + "simulator_server", + "cdp", + "npm", + "npx", + "unknown", +] as const; + +export type FailureCommand = (typeof FAILURE_COMMANDS)[number]; + +export const FAILURE_SIGNAL_NAMES = [ + "SIGABRT", + "SIGHUP", + "SIGINT", + "SIGKILL", + "SIGQUIT", + "SIGTERM", +] as const; + +export type FailureSignalName = (typeof FAILURE_SIGNAL_NAMES)[number]; + +export const FAILURE_SPAWN_CODES = ["EACCES", "ENOENT", "EPERM", "ETIMEDOUT"] as const; + +export type FailureSpawnCode = (typeof FAILURE_SPAWN_CODES)[number]; + +export const NETWORK_FAILURES = [ + "timeout", + "connection_refused", + "connection_reset", + "invalid_response", + "unavailable", + "other", +] as const; + +export type NetworkFailure = (typeof NETWORK_FAILURES)[number]; + +export interface FailureSignal { + /** Static, searchable code. Never derive this from an Error message. */ + error_code: FailureCode; + /** Static source-location hint, e.g. `http_zod_validation` or `registry_execute`. */ + failure_stage: string; + failure_area: FailureArea; + error_kind: FailureKind; + /** Optional coarse command category; never a command line or argv. */ + failure_command?: FailureCommand; + /** Optional process exit code; sanitized to a small non-negative integer. */ + failure_exit_code?: number; + /** Optional allowlisted POSIX signal name. */ + failure_signal?: FailureSignalName; + /** Optional allowlisted spawn failure code. */ + failure_spawn_code?: FailureSpawnCode; + /** Optional coarse network failure class; never a URL, host, or port. */ + network_failure?: NetworkFailure; +} + +const FAILURE_SIGNAL = Symbol("argent.failure_signal"); + +const FALLBACK_SIGNAL: FailureSignal = { + error_code: FAILURE_CODES.ARGENT_UNCLASSIFIED_FAILURE, + failure_stage: "unclassified", + failure_area: "registry", + error_kind: "unknown", +}; + +export class FailureError extends Error { + constructor(message: string, signal: FailureSignal, options?: { cause?: Error }) { + super(message, options); + this.name = "FailureError"; + withFailureSignal(this, signal); + } +} + +export function failureSignal( + error_code: FailureCode, + failure_stage: string, + failure_area: FailureArea, + error_kind: FailureKind +): FailureSignal { + return { error_code, failure_stage, failure_area, error_kind }; +} + +export function withFailureSignal(error: T, signal: FailureSignal): T { + Object.defineProperty(error, FAILURE_SIGNAL, { + value: signal, + enumerable: false, + configurable: true, + }); + return error; +} + +export function getFailureSignal(error: unknown): FailureSignal | null { + let current: unknown = error; + for (let depth = 0; depth < 8 && current instanceof Error; depth++) { + const signal = (current as Error & { [FAILURE_SIGNAL]?: FailureSignal })[FAILURE_SIGNAL]; + if (signal) return signal; + current = current.cause; + } + return null; +} + +export function getFailureSignalOrFallback( + error: unknown, + fallback: FailureSignal = FALLBACK_SIGNAL +): FailureSignal { + return getFailureSignal(error) ?? fallback; +} + +export function wrapFailure( + error: unknown, + fallback: FailureSignal, + message?: string +): FailureError { + const cause = error instanceof Error ? error : new Error(String(error)); + return new FailureError(message ?? cause.message, getFailureSignalOrFallback(cause, fallback), { + cause, + }); +} + +const FAILURE_SIGNAL_NAME_SET = new Set(FAILURE_SIGNAL_NAMES); + +const FAILURE_SPAWN_CODE_SET = new Set(FAILURE_SPAWN_CODES); + +export function subprocessFailureMetadata( + error: unknown, + failure_command: FailureCommand +): Pick< + FailureSignal, + "failure_command" | "failure_exit_code" | "failure_signal" | "failure_spawn_code" +> { + const metadata: Pick< + FailureSignal, + "failure_command" | "failure_exit_code" | "failure_signal" | "failure_spawn_code" + > = { failure_command }; + const err = error as { + code?: string | number | null; + signal?: string | null; + }; + if ( + typeof err.code === "number" && + Number.isInteger(err.code) && + err.code >= 0 && + err.code <= 255 + ) { + metadata.failure_exit_code = err.code; + } else if ( + typeof err.code === "string" && + FAILURE_SPAWN_CODE_SET.has(err.code as FailureSpawnCode) + ) { + metadata.failure_spawn_code = err.code as FailureSpawnCode; + } + if ( + typeof err.signal === "string" && + FAILURE_SIGNAL_NAME_SET.has(err.signal as FailureSignalName) + ) { + metadata.failure_signal = err.signal as FailureSignalName; + } + return metadata; +} + export class ServiceNotFoundError extends Error { public readonly serviceId: string; constructor(serviceId: string) { super(`Service "${serviceId}" not found`); this.name = "ServiceNotFoundError"; this.serviceId = serviceId; + withFailureSignal(this, { + error_code: FAILURE_CODES.REGISTRY_SERVICE_NOT_FOUND, + failure_stage: "registry_resolve_service", + failure_area: "registry", + error_kind: "not_found", + }); } } @@ -15,6 +208,15 @@ export class ServiceInitializationError extends Error { super(`[${serviceId}] ${message}`, options); this.name = "ServiceInitializationError"; this.serviceId = serviceId; + withFailureSignal( + this, + getFailureSignalOrFallback(options?.cause, { + error_code: FAILURE_CODES.REGISTRY_SERVICE_INITIALIZATION_FAILED, + failure_stage: "registry_initialize_service", + failure_area: "registry", + error_kind: "unknown", + }) + ); } } @@ -26,6 +228,12 @@ export class ToolNotFoundError extends Error { super(`Tool "${toolId}" not found`); this.name = "ToolNotFoundError"; this.toolId = toolId; + withFailureSignal(this, { + error_code: FAILURE_CODES.REGISTRY_TOOL_NOT_FOUND, + failure_stage: "registry_lookup_tool", + failure_area: "registry", + error_kind: "not_found", + }); } } @@ -35,5 +243,14 @@ export class ToolExecutionError extends Error { super(`[Tool:${toolId}] ${message}`, options); this.name = "ToolExecutionError"; this.toolId = toolId; + withFailureSignal( + this, + getFailureSignalOrFallback(options?.cause, { + error_code: FAILURE_CODES.REGISTRY_TOOL_EXECUTION_FAILED, + failure_stage: "registry_execute_tool", + failure_area: "registry", + error_kind: "unknown", + }) + ); } } diff --git a/packages/registry/src/failure-codes.ts b/packages/registry/src/failure-codes.ts new file mode 100644 index 00000000..eab71b08 --- /dev/null +++ b/packages/registry/src/failure-codes.ts @@ -0,0 +1,188 @@ +export const FAILURE_CODES = { + ARGENT_UNCLASSIFIED_FAILURE: "ARGENT_UNCLASSIFIED_FAILURE", + + REGISTRY_SERVICE_NOT_FOUND: "REGISTRY_SERVICE_NOT_FOUND", + REGISTRY_SERVICE_INITIALIZATION_FAILED: "REGISTRY_SERVICE_INITIALIZATION_FAILED", + REGISTRY_TOOL_NOT_FOUND: "REGISTRY_TOOL_NOT_FOUND", + REGISTRY_TOOL_EXECUTION_FAILED: "REGISTRY_TOOL_EXECUTION_FAILED", + REGISTRY_TOOL_FAILURE_UNCLASSIFIED: "REGISTRY_TOOL_FAILURE_UNCLASSIFIED", + + HTTP_TOOL_NOT_FOUND: "HTTP_TOOL_NOT_FOUND", + HTTP_ZOD_VALIDATION_FAILED: "HTTP_ZOD_VALIDATION_FAILED", + HTTP_CAPABILITY_UNSUPPORTED_OPERATION: "HTTP_CAPABILITY_UNSUPPORTED_OPERATION", + HTTP_DEVICE_RESOLUTION_FAILED: "HTTP_DEVICE_RESOLUTION_FAILED", + HTTP_DEPENDENCY_PREFLIGHT_MISSING: "HTTP_DEPENDENCY_PREFLIGHT_MISSING", + + CLI_RUN_TOOL_NOT_FOUND: "CLI_RUN_TOOL_NOT_FOUND", + CLI_RUN_FLAG_PARSE_FAILED: "CLI_RUN_FLAG_PARSE_FAILED", + CLI_RUN_ARGS_NOT_OBJECT: "CLI_RUN_ARGS_NOT_OBJECT", + CLI_RUN_ARGS_JSON_INVALID: "CLI_RUN_ARGS_JSON_INVALID", + CLI_RUN_TOOL_CALL_FAILED: "CLI_RUN_TOOL_CALL_FAILED", + CLI_RUN_SAVE_IMAGE_FAILED: "CLI_RUN_SAVE_IMAGE_FAILED", + + TOOL_CAPABILITY_UNSUPPORTED_OPERATION: "TOOL_CAPABILITY_UNSUPPORTED_OPERATION", + TOOL_PLATFORM_NOT_IMPLEMENTED: "TOOL_PLATFORM_NOT_IMPLEMENTED", + TOOL_DEPENDENCY_MISSING: "TOOL_DEPENDENCY_MISSING", + + TOOLSERVER_UNHANDLED_REJECTION: "TOOLSERVER_UNHANDLED_REJECTION", + TOOLSERVER_UNCAUGHT_EXCEPTION: "TOOLSERVER_UNCAUGHT_EXCEPTION", + + INSTALL_GLOBAL_PACKAGE_FAILED: "INSTALL_GLOBAL_PACKAGE_FAILED", + INSTALL_FROM_TAR_PACKAGE_FAILED: "INSTALL_FROM_TAR_PACKAGE_FAILED", + INSTALL_REGISTRY_CHECK_FAILED: "INSTALL_REGISTRY_CHECK_FAILED", + INSTALL_INIT_TRIGGERED_UPDATE_FAILED: "INSTALL_INIT_TRIGGERED_UPDATE_FAILED", + INSTALL_SKILLS_REFRESH_FAILED: "INSTALL_SKILLS_REFRESH_FAILED", + + UPDATE_INSTALLED_VERSION_DETECT_FAILED: "UPDATE_INSTALLED_VERSION_DETECT_FAILED", + UPDATE_REGISTRY_CHECK_FAILED: "UPDATE_REGISTRY_CHECK_FAILED", + UPDATE_TOOLSERVER_STOP_FAILED: "UPDATE_TOOLSERVER_STOP_FAILED", + UPDATE_PACKAGE_ACTION_FAILED: "UPDATE_PACKAGE_ACTION_FAILED", + UPDATE_UNCLASSIFIED_FAILED: "UPDATE_UNCLASSIFIED_FAILED", + + UNINSTALL_TOOLSERVER_STOP_FAILED: "UNINSTALL_TOOLSERVER_STOP_FAILED", + UNINSTALL_PACKAGE_ACTION_FAILED: "UNINSTALL_PACKAGE_ACTION_FAILED", + + ANDROID_ADB_NOT_FOUND: "ANDROID_ADB_NOT_FOUND", + ANDROID_EMULATOR_NOT_FOUND: "ANDROID_EMULATOR_NOT_FOUND", + ANDROID_ADB_COMMAND_FAILED: "ANDROID_ADB_COMMAND_FAILED", + ANDROID_ADB_BOOT_TERMINAL_STATE: "ANDROID_ADB_BOOT_TERMINAL_STATE", + ANDROID_ADB_BOOT_TIMEOUT: "ANDROID_ADB_BOOT_TIMEOUT", + + SIMULATOR_NETWORK_TIMEOUT: "SIMULATOR_NETWORK_TIMEOUT", + SIMULATOR_NETWORK_CONNECTION_REFUSED: "SIMULATOR_NETWORK_CONNECTION_REFUSED", + SIMULATOR_NETWORK_CONNECTION_RESET: "SIMULATOR_NETWORK_CONNECTION_RESET", + SIMULATOR_NETWORK_ERROR: "SIMULATOR_NETWORK_ERROR", + SIMULATOR_NON_JSON_RESPONSE: "SIMULATOR_NON_JSON_RESPONSE", + SIMULATOR_HTTP_ERROR_RESPONSE: "SIMULATOR_HTTP_ERROR_RESPONSE", + SIMULATOR_MISSING_RESPONSE_FIELDS: "SIMULATOR_MISSING_RESPONSE_FIELDS", + SIMULATOR_SERVER_FACTORY_OPTIONS_MISSING: "SIMULATOR_SERVER_FACTORY_OPTIONS_MISSING", + SIMULATOR_SERVER_DEVICE_ID_INVALID: "SIMULATOR_SERVER_DEVICE_ID_INVALID", + SIMULATOR_SERVER_READY_EXITED: "SIMULATOR_SERVER_READY_EXITED", + SIMULATOR_SERVER_READY_TIMEOUT: "SIMULATOR_SERVER_READY_TIMEOUT", + SIMULATOR_SERVER_PROCESS_ERROR: "SIMULATOR_SERVER_PROCESS_ERROR", + SIMULATOR_SERVER_TERMINATED: "SIMULATOR_SERVER_TERMINATED", + + AX_QUERY_TIMEOUT: "AX_QUERY_TIMEOUT", + AX_DAEMON_READY_TIMEOUT: "AX_DAEMON_READY_TIMEOUT", + AX_DAEMON_EXITED_BEFORE_READY: "AX_DAEMON_EXITED_BEFORE_READY", + AX_DAEMON_PROCESS_ERROR: "AX_DAEMON_PROCESS_ERROR", + AX_FACTORY_OPTIONS_MISSING: "AX_FACTORY_OPTIONS_MISSING", + AX_WRONG_PLATFORM: "AX_WRONG_PLATFORM", + AX_DEVICE_ID_INVALID: "AX_DEVICE_ID_INVALID", + AX_DESCRIBE_ERROR: "AX_DESCRIBE_ERROR", + AX_QUERY_FAILED: "AX_QUERY_FAILED", + + ANDROID_LAUNCH_ACTIVITY_RESOLVE_FAILED: "ANDROID_LAUNCH_ACTIVITY_RESOLVE_FAILED", + ANDROID_LAUNCH_AM_START_FAILED: "ANDROID_LAUNCH_AM_START_FAILED", + ANDROID_OPEN_URL_FAILED: "ANDROID_OPEN_URL_FAILED", + ANDROID_REINSTALL_INSTALL_FAILED: "ANDROID_REINSTALL_INSTALL_FAILED", + ANDROID_RESTART_FAILED: "ANDROID_RESTART_FAILED", + IOS_LAUNCH_SIMCTL_FAILED: "IOS_LAUNCH_SIMCTL_FAILED", + IOS_OPEN_URL_FAILED: "IOS_OPEN_URL_FAILED", + IOS_REINSTALL_INSTALL_FAILED: "IOS_REINSTALL_INSTALL_FAILED", + IOS_RESTART_LAUNCH_FAILED: "IOS_RESTART_LAUNCH_FAILED", + NATIVE_DEVTOOLS_DESCRIBE_ERROR: "NATIVE_DEVTOOLS_DESCRIBE_ERROR", + NATIVE_DEVTOOLS_VIEW_AT_POINT_ERROR: "NATIVE_DEVTOOLS_VIEW_AT_POINT_ERROR", + NATIVE_DEVTOOLS_USER_INTERACTABLE_VIEW_AT_POINT_ERROR: + "NATIVE_DEVTOOLS_USER_INTERACTABLE_VIEW_AT_POINT_ERROR", + NATIVE_DEVTOOLS_FIND_VIEWS_ERROR: "NATIVE_DEVTOOLS_FIND_VIEWS_ERROR", + NATIVE_DEVTOOLS_FACTORY_OPTIONS_MISSING: "NATIVE_DEVTOOLS_FACTORY_OPTIONS_MISSING", + NATIVE_DEVTOOLS_WRONG_PLATFORM: "NATIVE_DEVTOOLS_WRONG_PLATFORM", + NATIVE_DEVTOOLS_NOT_CONNECTED: "NATIVE_DEVTOOLS_NOT_CONNECTED", + NATIVE_DEVTOOLS_RPC_TIMEOUT: "NATIVE_DEVTOOLS_RPC_TIMEOUT", + NATIVE_DEVTOOLS_RPC_ERROR: "NATIVE_DEVTOOLS_RPC_ERROR", + NATIVE_DEVTOOLS_SERVICE_DISPOSED: "NATIVE_DEVTOOLS_SERVICE_DISPOSED", + NATIVE_TARGET_NO_CONNECTED_APPS: "NATIVE_TARGET_NO_CONNECTED_APPS", + NATIVE_TARGET_SINGLE_APP_NOT_FOREGROUND: "NATIVE_TARGET_SINGLE_APP_NOT_FOREGROUND", + NATIVE_TARGET_MULTIPLE_APPS_AMBIGUOUS: "NATIVE_TARGET_MULTIPLE_APPS_AMBIGUOUS", + + ANDROID_DEVTOOLS_ADB_NOT_FOUND: "ANDROID_DEVTOOLS_ADB_NOT_FOUND", + ANDROID_DEVTOOLS_ADB_FORWARD_UNEXPECTED: "ANDROID_DEVTOOLS_ADB_FORWARD_UNEXPECTED", + ANDROID_DEVTOOLS_HELPER_EXITED_BEFORE_READY: "ANDROID_DEVTOOLS_HELPER_EXITED_BEFORE_READY", + ANDROID_DEVTOOLS_HELPER_PROCESS_ERROR: "ANDROID_DEVTOOLS_HELPER_PROCESS_ERROR", + ANDROID_DEVTOOLS_HELPER_READY_TIMEOUT: "ANDROID_DEVTOOLS_HELPER_READY_TIMEOUT", + ANDROID_DEVTOOLS_FACTORY_OPTIONS_MISSING: "ANDROID_DEVTOOLS_FACTORY_OPTIONS_MISSING", + ANDROID_DEVTOOLS_WRONG_PLATFORM: "ANDROID_DEVTOOLS_WRONG_PLATFORM", + ANDROID_DEVTOOLS_DEVICE_ID_INVALID: "ANDROID_DEVTOOLS_DEVICE_ID_INVALID", + ANDROID_DEVTOOLS_HELPER_TERMINATED: "ANDROID_DEVTOOLS_HELPER_TERMINATED", + ANDROID_DEVTOOLS_RPC_CLIENT_CLOSED: "ANDROID_DEVTOOLS_RPC_CLIENT_CLOSED", + ANDROID_DEVTOOLS_RPC_ERROR: "ANDROID_DEVTOOLS_RPC_ERROR", + ANDROID_DEVTOOLS_RPC_TIMEOUT: "ANDROID_DEVTOOLS_RPC_TIMEOUT", + ANDROID_DEVTOOLS_SOCKET_CLOSED: "ANDROID_DEVTOOLS_SOCKET_CLOSED", + + ANDROID_SCREEN_SIZE_PARSE_FAILED: "ANDROID_SCREEN_SIZE_PARSE_FAILED", + ANDROID_SCREEN_SIZE_NON_POSITIVE: "ANDROID_SCREEN_SIZE_NON_POSITIVE", + ANDROID_UIAUTOMATOR_PARSE_FAILED: "ANDROID_UIAUTOMATOR_PARSE_FAILED", + + DEBUGGER_METRO_NOT_RUNNING: "DEBUGGER_METRO_NOT_RUNNING", + DEBUGGER_METRO_PROJECT_ROOT_MISSING: "DEBUGGER_METRO_PROJECT_ROOT_MISSING", + DEBUGGER_METRO_NO_TARGETS: "DEBUGGER_METRO_NO_TARGETS", + DEBUGGER_CDP_RUNTIME_EXCEPTION: "DEBUGGER_CDP_RUNTIME_EXCEPTION", + DEBUGGER_CDP_BINDING_TIMEOUT: "DEBUGGER_CDP_BINDING_TIMEOUT", + DEBUGGER_CDP_PROTOCOL_ERROR: "DEBUGGER_CDP_PROTOCOL_ERROR", + DEBUGGER_RELOAD_FAILED: "DEBUGGER_RELOAD_FAILED", + JS_RUNTIME_CONSOLE_SERVER_BIND_FAILED: "JS_RUNTIME_CONSOLE_SERVER_BIND_FAILED", + JS_RUNTIME_PAYLOAD_INVALID: "JS_RUNTIME_PAYLOAD_INVALID", + JS_RUNTIME_PAYLOAD_DEVICE_MISSING: "JS_RUNTIME_PAYLOAD_DEVICE_MISSING", + JS_RUNTIME_PAYLOAD_PORT_INVALID: "JS_RUNTIME_PAYLOAD_PORT_INVALID", + JS_RUNTIME_CDP_DISCONNECTED: "JS_RUNTIME_CDP_DISCONNECTED", + NETWORK_INSPECTOR_CDP_DISCONNECTED: "NETWORK_INSPECTOR_CDP_DISCONNECTED", + + REACT_PROFILER_NO_ACTIVE_SESSION: "REACT_PROFILER_NO_ACTIVE_SESSION", + REACT_PROFILER_CDP_CONNECTION_LOST: "REACT_PROFILER_CDP_CONNECTION_LOST", + REACT_PROFILER_NO_CPU_PROFILE: "REACT_PROFILER_NO_CPU_PROFILE", + REACT_PROFILER_RUNTIME_EXCEPTION: "REACT_PROFILER_RUNTIME_EXCEPTION", + REACT_PROFILER_NO_RUNTIME_DATA: "REACT_PROFILER_NO_RUNTIME_DATA", + REACT_PROFILER_SESSION_PAYLOAD_INVALID: "REACT_PROFILER_SESSION_PAYLOAD_INVALID", + REACT_PROFILER_SESSION_PAYLOAD_DEVICE_MISSING: "REACT_PROFILER_SESSION_PAYLOAD_DEVICE_MISSING", + REACT_PROFILER_SESSION_CDP_DISCONNECTED: "REACT_PROFILER_SESSION_CDP_DISCONNECTED", + REACT_PROFILER_CDP_NOT_CONNECTED: "REACT_PROFILER_CDP_NOT_CONNECTED", + REACT_PROFILER_STATE_READ_FAILED: "REACT_PROFILER_STATE_READ_FAILED", + REACT_PROFILER_DEVTOOLS_HOOK_MISSING: "REACT_PROFILER_DEVTOOLS_HOOK_MISSING", + REACT_PROFILER_DEVTOOLS_BACKEND_ATTACH_FAILED: "REACT_PROFILER_DEVTOOLS_BACKEND_ATTACH_FAILED", + REACT_PROFILER_DEVTOOLS_BACKEND_BOOTSTRAP_FAILED: + "REACT_PROFILER_DEVTOOLS_BACKEND_BOOTSTRAP_FAILED", + REACT_PROFILER_DEVTOOLS_RENDERER_MISSING: "REACT_PROFILER_DEVTOOLS_RENDERER_MISSING", + REACT_PROFILER_START_FAILED: "REACT_PROFILER_START_FAILED", + REACT_PROFILER_START_VERIFY_FAILED: "REACT_PROFILER_START_VERIFY_FAILED", + REACT_PROFILER_HOOK_ERROR: "REACT_PROFILER_HOOK_ERROR", + REACT_PROFILER_ANALYZE_NO_DATA: "REACT_PROFILER_ANALYZE_NO_DATA", + + NATIVE_PROFILER_FACTORY_OPTIONS_MISSING: "NATIVE_PROFILER_FACTORY_OPTIONS_MISSING", + NATIVE_PROFILER_WRONG_PLATFORM: "NATIVE_PROFILER_WRONG_PLATFORM", + NATIVE_PROFILER_APP_PROCESS_LIST_FAILED: "NATIVE_PROFILER_APP_PROCESS_LIST_FAILED", + NATIVE_PROFILER_APP_LIST_FAILED: "NATIVE_PROFILER_APP_LIST_FAILED", + NATIVE_PROFILER_NO_RUNNING_APPS: "NATIVE_PROFILER_NO_RUNNING_APPS", + NATIVE_PROFILER_NO_RUNNING_USER_APPS: "NATIVE_PROFILER_NO_RUNNING_USER_APPS", + NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS: "NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS", + NATIVE_PROFILER_SESSION_ALREADY_RUNNING: "NATIVE_PROFILER_SESSION_ALREADY_RUNNING", + NATIVE_PROFILER_XCTRACE_NO_PID: "NATIVE_PROFILER_XCTRACE_NO_PID", + NATIVE_PROFILER_XCTRACE_PROCESS_NOT_FOUND: "NATIVE_PROFILER_XCTRACE_PROCESS_NOT_FOUND", + NATIVE_PROFILER_XCTRACE_READY_EXITED: "NATIVE_PROFILER_XCTRACE_READY_EXITED", + NATIVE_PROFILER_XCTRACE_PROCESS_ERROR: "NATIVE_PROFILER_XCTRACE_PROCESS_ERROR", + NATIVE_PROFILER_XCTRACE_READY_TIMEOUT: "NATIVE_PROFILER_XCTRACE_READY_TIMEOUT", + NATIVE_PROFILER_NO_ACTIVE_SESSION: "NATIVE_PROFILER_NO_ACTIVE_SESSION", + + FLOW_PROJECT_ROOT_REQUIRED: "FLOW_PROJECT_ROOT_REQUIRED", + FLOW_NO_ACTIVE_RECORDING: "FLOW_NO_ACTIVE_RECORDING", + FLOW_FILE_INVALID: "FLOW_FILE_INVALID", + FLOW_ENTRY_UNRECOGNIZED: "FLOW_ENTRY_UNRECOGNIZED", + PROFILER_QUERY_MODE_INVALID: "PROFILER_QUERY_MODE_INVALID", + PROFILER_QUERY_REQUIRED_PARAM_MISSING: "PROFILER_QUERY_REQUIRED_PARAM_MISSING", + PROFILER_DATA_NOT_LOADED: "PROFILER_DATA_NOT_LOADED", + PROFILER_NATIVE_TRACE_MISSING: "PROFILER_NATIVE_TRACE_MISSING", + + BOOT_DEVICE_TARGET_SELECTION_INVALID: "BOOT_DEVICE_TARGET_SELECTION_INVALID", + BOOT_IOS_UNSUPPORTED_HOST: "BOOT_IOS_UNSUPPORTED_HOST", + BOOT_ANDROID_NO_AVDS: "BOOT_ANDROID_NO_AVDS", + BOOT_ANDROID_AVD_NOT_FOUND: "BOOT_ANDROID_AVD_NOT_FOUND", + BOOT_ANDROID_ADB_UNAVAILABLE: "BOOT_ANDROID_ADB_UNAVAILABLE", + BOOT_ANDROID_GPU_MODE_INVALID: "BOOT_ANDROID_GPU_MODE_INVALID", + BOOT_ANDROID_ADB_REGISTER_TIMEOUT: "BOOT_ANDROID_ADB_REGISTER_TIMEOUT", + BOOT_ANDROID_HOT_BOOT_FRAME_UNUSABLE: "BOOT_ANDROID_HOT_BOOT_FRAME_UNUSABLE", + BOOT_ANDROID_FIRST_FRAME_TIMEOUT: "BOOT_ANDROID_FIRST_FRAME_TIMEOUT", + BOOT_ANDROID_PACKAGE_MANAGER_UNAVAILABLE: "BOOT_ANDROID_PACKAGE_MANAGER_UNAVAILABLE", + BOOT_ANDROID_COLD_BOOT_FAILED: "BOOT_ANDROID_COLD_BOOT_FAILED", +} as const; + +export type FailureCode = (typeof FAILURE_CODES)[keyof typeof FAILURE_CODES]; diff --git a/packages/registry/src/index.ts b/packages/registry/src/index.ts index 712712dd..2d7af8e1 100644 --- a/packages/registry/src/index.ts +++ b/packages/registry/src/index.ts @@ -23,7 +23,31 @@ export { ServiceInitializationError, ToolNotFoundError, ToolExecutionError, + FailureError, + FAILURE_AREAS, + FAILURE_COMMANDS, + FAILURE_KINDS, + FAILURE_SIGNAL_NAMES, + FAILURE_SPAWN_CODES, + NETWORK_FAILURES, + failureSignal, + subprocessFailureMetadata, + withFailureSignal, + wrapFailure, + getFailureSignal, + getFailureSignalOrFallback, } from "./errors"; +export type { + FailureArea, + FailureCommand, + FailureKind, + FailureSignal, + FailureSignalName, + FailureSpawnCode, + NetworkFailure, +} from "./errors"; +export { FAILURE_CODES } from "./failure-codes"; +export type { FailureCode } from "./failure-codes"; export { Registry } from "./registry"; export { attachRegistryLogger } from "./logger"; export { zodObjectToJsonSchema } from "./zod-to-json-schema"; diff --git a/packages/registry/tests/registry-error-events.test.ts b/packages/registry/tests/registry-error-events.test.ts index 596e1391..d4ceb20f 100644 --- a/packages/registry/tests/registry-error-events.test.ts +++ b/packages/registry/tests/registry-error-events.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect } from "vitest"; import { Registry } from "../src/registry"; import { ServiceState } from "../src/types"; -import { ServiceInitializationError } from "../src/errors"; +import { + getFailureSignal, + ServiceInitializationError, + subprocessFailureMetadata, + withFailureSignal, +} from "../src/errors"; +import { FAILURE_CODES } from "../src/failure-codes"; import { createStaticBlueprint, staticUrn } from "./helpers"; describe("Registry — serviceError events carry cause", () => { @@ -139,3 +145,59 @@ describe("Registry — serviceError events carry cause", () => { expect(errors.find((e) => e.serviceId === staticUrn("Clean"))).toBeUndefined(); }); }); + +describe("Registry — failure signals", () => { + it("preserves a safe failure signal when wrapping tool execution errors", async () => { + const registry = new Registry(); + registry.registerTool({ + id: "failing-tool", + services: () => ({}), + async execute() { + throw withFailureSignal(new Error("private /Users/alice/project failure"), { + error_code: FAILURE_CODES.TOOL_DEPENDENCY_MISSING, + failure_stage: "failing_tool_execute", + failure_area: "tool_server", + error_kind: "unknown", + }); + }, + }); + + let emittedError: Error | null = null; + registry.events.on("toolFailed", (_toolId, _toolInvocationId, error) => { + emittedError = error; + }); + + await expect(registry.invokeTool("failing-tool")).rejects.toThrow(); + + expect(emittedError).toBeInstanceOf(Error); + expect(getFailureSignal(emittedError)).toEqual({ + error_code: FAILURE_CODES.TOOL_DEPENDENCY_MISSING, + failure_stage: "failing_tool_execute", + failure_area: "tool_server", + error_kind: "unknown", + }); + }); + + it("derives only safe subprocess metadata", () => { + expect( + subprocessFailureMetadata( + { code: 127, signal: "SIGKILL", stderr: "secret", message: "/Users/alice/private" }, + "xcrun_simctl" + ) + ).toEqual({ + failure_command: "xcrun_simctl", + failure_exit_code: 127, + failure_signal: "SIGKILL", + }); + + expect( + subprocessFailureMetadata( + { code: "ENOENT", signal: "SIGUSR1", path: "/Users/alice/private" }, + "adb" + ) + ).toEqual({ + failure_command: "adb", + failure_spawn_code: "ENOENT", + }); + }); +}); diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index e7aecae6..85320a91 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -12,6 +12,7 @@ "typecheck:tests": "tsc --noEmit -p tsconfig.test.json" }, "dependencies": { + "@argent/registry": "file:../registry", "ci-info": "^4.4.0", "posthog-node": "5.35.0" }, diff --git a/packages/telemetry/src/consent.ts b/packages/telemetry/src/consent.ts index b9baf202..c9996668 100644 --- a/packages/telemetry/src/consent.ts +++ b/packages/telemetry/src/consent.ts @@ -1,4 +1,6 @@ +import * as crypto from "node:crypto"; import * as fs from "node:fs"; +import * as path from "node:path"; import { argentHomeDir, configFilePath } from "./paths.js"; // Consent is evaluated on every track() so a running tool server sees opt-outs. @@ -142,7 +144,27 @@ export function writeConsentFlag(enabled: boolean): void { }, }; - fs.writeFileSync(configFilePath(), JSON.stringify(next, null, 2) + "\n"); + // Atomic publish: write to a temp file then rename so an interrupted write + // can never truncate the shared config (which holds non-telemetry keys too). + const finalPath = configFilePath(); + const tmpPath = path.join(argentHomeDir(), `.config.tmp.${process.pid}.${crypto.randomUUID()}`); + const fd = fs.openSync(tmpPath, "wx", 0o600); + try { + fs.writeSync(fd, JSON.stringify(next, null, 2) + "\n"); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + try { + fs.renameSync(tmpPath, finalPath); + } catch (err) { + try { + fs.unlinkSync(tmpPath); + } catch { + /* nothing to clean up */ + } + throw err; + } cache.current = null; } diff --git a/packages/telemetry/src/erasure.ts b/packages/telemetry/src/erasure.ts index 344e795b..99a28391 100644 --- a/packages/telemetry/src/erasure.ts +++ b/packages/telemetry/src/erasure.ts @@ -1,7 +1,5 @@ -import * as fs from "node:fs"; import { deleteAnonId } from "./identity.js"; import { writeConsentFlag } from "./consent.js"; -import { configFilePath } from "./paths.js"; import { emitDebugError } from "./debug.js"; export interface ForgetOptions { @@ -39,18 +37,5 @@ export async function forget(options: ForgetOptions = {}): Promise emitDebugError("forget: deleting telemetry-id failed", err); } - if (disableConsent) { - // Preserve config unless the write left an empty object behind. - try { - const raw = fs.readFileSync(configFilePath(), "utf8"); - const json = JSON.parse(raw) as Record; - if (json && typeof json === "object" && Object.keys(json).length === 0) { - fs.unlinkSync(configFilePath()); - } - } catch { - /* leave config in whatever state it ended up in */ - } - } - return { localIdRemoved, consentDisabled }; } diff --git a/packages/telemetry/src/events.ts b/packages/telemetry/src/events.ts index 31887436..e8eda6d4 100644 --- a/packages/telemetry/src/events.ts +++ b/packages/telemetry/src/events.ts @@ -1,14 +1,18 @@ // Typed telemetry event names and property shapes. sanitize.ts enforces the // same surface at runtime. +import type { FailureSignal } from "@argent/registry"; + // Installation events +export type FailureTelemetryProps = Partial; + export interface InstallationCliInitStartProps { package_manager: "npm" | "yarn" | "pnpm" | "bun" | "unknown"; is_non_interactive: boolean; } -export interface InstallationCliInitCompleteProps { +export interface InstallationCliInitCompleteProps extends FailureTelemetryProps { duration_ms: number; is_success: boolean; editors_configured_count: number; @@ -47,6 +51,14 @@ export interface InstallationSkillInstallProps { outcome: "success" | "failure" | "skipped"; } +export interface InstallationSkillRefreshResultProps extends FailureTelemetryProps { + is_success: boolean; + scope_count: number; + synced_count: number; + pruned_count: number; + failed_count: number; +} + export type InstallationPackageActionTrigger = "init" | "update" | "mcp_update"; export type InstallationPackageAction = @@ -60,7 +72,7 @@ export type InstallationPackageAction = | "standalone_install" | "mcp_update"; -export interface InstallationPackageActionProps { +export interface InstallationPackageActionProps extends FailureTelemetryProps { trigger: InstallationPackageActionTrigger; action: InstallationPackageAction; is_success: boolean; @@ -73,13 +85,13 @@ export interface InstallationCliUpdateCompleteProps { duration_ms: number; } -export interface InstallationCliUpdateFailProps { +export interface InstallationCliUpdateFailProps extends FailureTelemetryProps { duration_ms: number; } export interface InstallationCliUninstallStartProps {} -export interface InstallationCliUninstallCompleteProps { +export interface InstallationCliUninstallCompleteProps extends FailureTelemetryProps { has_pruned_content: boolean; has_uninstalled_package: boolean; } @@ -99,18 +111,25 @@ export interface ToolCompleteProps { duration_ms: number; } -export interface ToolFailProps { +export interface ToolFailProps extends FailureTelemetryProps { tool: string; - tool_invocation_id: string; + tool_invocation_id?: string; platform?: "ios" | "android"; duration_ms: number; } +// CLI command events + +export interface CliRunFailProps extends FailureTelemetryProps { + tool: string; + duration_ms: number; +} + // Lifecycle events export interface ToolserverStartProps {} -export interface ToolserverStopProps { +export interface ToolserverStopProps extends FailureTelemetryProps { reason: "idle" | "signal" | "crash"; uptime_ms: number; total_tool_calls: number; @@ -131,6 +150,7 @@ export interface EventPropertyMap { "installation:editors_select": InstallationEditorsSelectProps; "installation:allowlist_decision": InstallationAllowlistDecisionProps; "installation:skill_install": InstallationSkillInstallProps; + "installation:skill_refresh_result": InstallationSkillRefreshResultProps; "installation:package_action": InstallationPackageActionProps; "installation:cli_update_start": InstallationCliUpdateStartProps; "installation:cli_update_complete": InstallationCliUpdateCompleteProps; @@ -140,6 +160,7 @@ export interface EventPropertyMap { "tool:invoke": ToolInvokeProps; "tool:complete": ToolCompleteProps; "tool:fail": ToolFailProps; + "cli:run_fail": CliRunFailProps; "toolserver:start": ToolserverStartProps; "toolserver:stop": ToolserverStopProps; "telemetry:opt_out": TelemetryOptOutProps; @@ -157,6 +178,7 @@ export const EVENT_NAMES: readonly EventName[] = [ "installation:editors_select", "installation:allowlist_decision", "installation:skill_install", + "installation:skill_refresh_result", "installation:package_action", "installation:cli_update_start", "installation:cli_update_complete", @@ -166,6 +188,7 @@ export const EVENT_NAMES: readonly EventName[] = [ "tool:invoke", "tool:complete", "tool:fail", + "cli:run_fail", "toolserver:start", "toolserver:stop", "telemetry:opt_out", diff --git a/packages/telemetry/src/identity.ts b/packages/telemetry/src/identity.ts index 676bd585..3c346ed1 100644 --- a/packages/telemetry/src/identity.ts +++ b/packages/telemetry/src/identity.ts @@ -57,6 +57,11 @@ export function readOrCreateAnonId(): string { throw new Error("telemetry: failed to create anonymous identity after retries"); } +/** Read the anonymous id without creating one. Returns null if absent. */ +export function peekAnonId(): string | null { + return tryReadId(identityFilePath()); +} + /** Delete the identity file. Used by uninstall cleanup. */ export function deleteAnonId(): void { try { diff --git a/packages/telemetry/src/index.ts b/packages/telemetry/src/index.ts index 1fa195f9..8f06d4d3 100644 --- a/packages/telemetry/src/index.ts +++ b/packages/telemetry/src/index.ts @@ -1,7 +1,6 @@ // Anonymous opt-out telemetry for Argent. Public functions swallow telemetry // failures and surface diagnostics only when ARGENT_TELEMETRY_DEBUG=1. -import * as fs from "node:fs"; import { getClient, getConstructedClient, @@ -11,8 +10,7 @@ import { } from "./posthog.js"; import { sanitize } from "./sanitize.js"; import { getBaseProps, type Runtime } from "./base-props.js"; -import { readOrCreateAnonId } from "./identity.js"; -import { identityFilePath } from "./paths.js"; +import { readOrCreateAnonId, peekAnonId } from "./identity.js"; import { isEnabled as consentIsEnabled, writeConsentFlag, getConsentState } from "./consent.js"; import { emitDebugError, emitDebugPayload, isDebugEnabled } from "./debug.js"; import { forget as forgetImpl, type ForgetOptions, type ForgetResult } from "./erasure.js"; @@ -209,17 +207,9 @@ export function status(): { const consent = getConsentState(); // Read the id without creating one; status must be side-effect free. - let anonIdPrefix: string | null = null; - let hasAnonIdOnDisk = false; - try { - const raw = fs.readFileSync(identityFilePath(), "utf8").trim(); - if (/^[0-9a-fA-F-]{32,128}$/.test(raw)) { - hasAnonIdOnDisk = true; - anonIdPrefix = raw.slice(0, 8); - } - } catch { - /* missing — keep nulls */ - } + const anonId = peekAnonId(); + const hasAnonIdOnDisk = anonId !== null; + const anonIdPrefix = anonId ? anonId.slice(0, 8) : null; const config = resolveConfig(); return { diff --git a/packages/telemetry/src/registry-listener.ts b/packages/telemetry/src/registry-listener.ts index 57357cf0..d517132a 100644 --- a/packages/telemetry/src/registry-listener.ts +++ b/packages/telemetry/src/registry-listener.ts @@ -1,4 +1,4 @@ -import type { Registry } from "@argent/registry"; +import { FAILURE_CODES, getFailureSignalOrFallback, type Registry } from "@argent/registry"; import { track } from "./index.js"; // HTTP captures request-only metadata here so registry lifecycle events can @@ -53,11 +53,18 @@ export function attachRegistryTelemetry(registry: Registry): AttachHandle { durationMs = 0 ): void => { const meta = consumeActiveMeta(toolInvocationId); + const signal = getFailureSignalOrFallback(error, { + error_code: FAILURE_CODES.REGISTRY_TOOL_FAILURE_UNCLASSIFIED, + failure_stage: "registry_tool_failed_event", + failure_area: "registry", + error_kind: "unknown", + }); track("tool:fail", { tool: toolId, tool_invocation_id: toolInvocationId, ...(meta.platform ? { platform: meta.platform } : {}), duration_ms: durationMs, + ...signal, }); }; diff --git a/packages/telemetry/src/sanitize.ts b/packages/telemetry/src/sanitize.ts index 2e6c7696..f3405a47 100644 --- a/packages/telemetry/src/sanitize.ts +++ b/packages/telemetry/src/sanitize.ts @@ -1,3 +1,12 @@ +import { + FAILURE_AREAS, + FAILURE_CODES, + FAILURE_COMMANDS, + FAILURE_KINDS, + FAILURE_SIGNAL_NAMES, + FAILURE_SPAWN_CODES, + NETWORK_FAILURES, +} from "@argent/registry"; import type { EventName } from "./events.js"; // Per-event property allowlist and validators. Unknown keys and invalid values @@ -61,6 +70,29 @@ const ADAPTER_NAME = matches(/^[a-z][a-z0-9-]{0,63}$/, 64); const COUNT = finiteNonNeg(); const DURATION_MS = finiteNonNeg(); const MAJOR_VERSION = finiteNonNeg(9999); +const FAILURE_CODE_VALUES = new Set(Object.values(FAILURE_CODES)); +const ERROR_CODE: Validator = (v) => + typeof v === "string" && FAILURE_CODE_VALUES.has(v) ? v : undefined; +const FAILURE_STAGE = matches(/^[a-z][a-z0-9_]{1,79}$/, 80); +const FAILURE_AREA = oneOf(FAILURE_AREAS); +const ERROR_KIND = oneOf(FAILURE_KINDS); +const FAILURE_COMMAND = oneOf(FAILURE_COMMANDS); +const FAILURE_EXIT_CODE = finiteNonNeg(255); +const FAILURE_SIGNAL_NAME = oneOf(FAILURE_SIGNAL_NAMES); +const FAILURE_SPAWN_CODE = oneOf(FAILURE_SPAWN_CODES); +const NETWORK_FAILURE = oneOf(NETWORK_FAILURES); + +const FAILURE_SIGNAL = { + error_code: ERROR_CODE, + failure_stage: FAILURE_STAGE, + failure_area: FAILURE_AREA, + error_kind: ERROR_KIND, + failure_command: FAILURE_COMMAND, + failure_exit_code: FAILURE_EXIT_CODE, + failure_signal: FAILURE_SIGNAL_NAME, + failure_spawn_code: FAILURE_SPAWN_CODE, + network_failure: NETWORK_FAILURE, +}; // Per-event validators @@ -73,6 +105,7 @@ export const ALLOWED: Record> = { duration_ms: DURATION_MS, is_success: bool, editors_configured_count: COUNT, + ...FAILURE_SIGNAL, }, "installation:cli_init_cancel": { step: oneOf(["global_install", "editors", "scope", "skills", "allowlist"] as const), @@ -100,11 +133,20 @@ export const ALLOWED: Record> = { has_offline_cache: bool, outcome: oneOf(["success", "failure", "skipped"] as const), }, + "installation:skill_refresh_result": { + is_success: bool, + scope_count: COUNT, + synced_count: COUNT, + pruned_count: COUNT, + failed_count: COUNT, + ...FAILURE_SIGNAL, + }, "installation:package_action": { trigger: PACKAGE_ACTION_TRIGGER, action: PACKAGE_ACTION, is_success: bool, duration_ms: DURATION_MS, + ...FAILURE_SIGNAL, }, "installation:cli_update_start": {}, "installation:cli_update_complete": { @@ -112,11 +154,13 @@ export const ALLOWED: Record> = { }, "installation:cli_update_fail": { duration_ms: DURATION_MS, + ...FAILURE_SIGNAL, }, "installation:cli_uninstall_start": {}, "installation:cli_uninstall_complete": { has_pruned_content: bool, has_uninstalled_package: bool, + ...FAILURE_SIGNAL, }, "tool:invoke": { tool: TOOL_NAME, @@ -134,12 +178,19 @@ export const ALLOWED: Record> = { tool_invocation_id: UUID, platform: PLATFORM, duration_ms: DURATION_MS, + ...FAILURE_SIGNAL, + }, + "cli:run_fail": { + tool: TOOL_NAME, + duration_ms: DURATION_MS, + ...FAILURE_SIGNAL, }, "toolserver:start": {}, "toolserver:stop": { reason: oneOf(["idle", "signal", "crash"] as const), uptime_ms: DURATION_MS, total_tool_calls: COUNT, + ...FAILURE_SIGNAL, }, "telemetry:opt_out": {}, }; diff --git a/packages/telemetry/test/registry-listener.test.ts b/packages/telemetry/test/registry-listener.test.ts index bec1cd77..8e630394 100644 --- a/packages/telemetry/test/registry-listener.test.ts +++ b/packages/telemetry/test/registry-listener.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { Registry } from "@argent/registry"; +import { FAILURE_CODES, Registry, withFailureSignal } from "@argent/registry"; import { attachRegistryTelemetry } from "../src/registry-listener.js"; import { scopeHome } from "./helpers.js"; import * as telemetry from "../src/index.js"; @@ -75,6 +75,10 @@ describe("attachRegistryTelemetry", () => { tool_invocation_id: INVOCATION_ID_1, platform: "android", duration_ms: 17.25, + error_code: "REGISTRY_TOOL_FAILURE_UNCLASSIFIED", + failure_stage: "registry_tool_failed_event", + failure_area: "registry", + error_kind: "unknown", }); // The error MESSAGE must never reach the payload. expect(JSON.stringify(trackSpy.mock.calls[1]![1])).not.toContain("ETIMEDOUT"); @@ -83,6 +87,37 @@ describe("attachRegistryTelemetry", () => { handle.detach(); }); + it("forwards static failure signals from wrapped errors", () => { + const trackSpy = vi.spyOn(telemetry, "track"); + const registry = new Registry(); + const handle = attachRegistryTelemetry(registry); + + const error = withFailureSignal(new Error("private /Users/alice/project failure"), { + error_code: FAILURE_CODES.TOOL_DEPENDENCY_MISSING, + failure_stage: "screenshot_execute", + failure_area: "tool_server", + error_kind: "unknown", + }); + + emitToolFailed(registry, "screenshot", INVOCATION_ID_1, error, 9); + + expect(trackSpy).toHaveBeenCalledWith( + "tool:fail", + expect.objectContaining({ + tool: "screenshot", + tool_invocation_id: INVOCATION_ID_1, + duration_ms: 9, + error_code: FAILURE_CODES.TOOL_DEPENDENCY_MISSING, + failure_stage: "screenshot_execute", + failure_area: "tool_server", + error_kind: "unknown", + }) + ); + expect(JSON.stringify(trackSpy.mock.calls[0]![1])).not.toContain("/Users/alice"); + + handle.detach(); + }); + it("totalToolCalls counter increments per invocation", () => { const registry = new Registry(); const handle = attachRegistryTelemetry(registry); diff --git a/packages/telemetry/test/sanitize.test.ts b/packages/telemetry/test/sanitize.test.ts index 07c25a69..c9f3a149 100644 --- a/packages/telemetry/test/sanitize.test.ts +++ b/packages/telemetry/test/sanitize.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { FAILURE_CODES } from "@argent/registry"; import { sanitize, ALLOWED } from "../src/sanitize.js"; import { EVENT_NAMES } from "../src/events.js"; @@ -88,6 +89,7 @@ describe("sanitize", () => { platform: "ios", duration_ms: 1, error_message: "ENOENT /Users/alice/.ssh/id_rsa", + stack: "Error: ENOENT\n at /Users/alice/project/app.ts:1", }) ).toEqual({ tool: "gesture-tap", @@ -95,6 +97,162 @@ describe("sanitize", () => { duration_ms: 1, }); }); + + it("allows static failure signal fields on tool failures", () => { + expect( + sanitize("tool:fail", { + tool: "gesture-tap", + platform: "ios", + duration_ms: 1, + error_code: "HTTP_ZOD_VALIDATION_FAILED", + failure_stage: "http_zod_validation", + failure_area: "http", + error_kind: "validation", + }) + ).toEqual({ + tool: "gesture-tap", + platform: "ios", + duration_ms: 1, + error_code: "HTTP_ZOD_VALIDATION_FAILED", + failure_stage: "http_zod_validation", + failure_area: "http", + error_kind: "validation", + }); + }); + + it("allows coarse subprocess and network failure metadata", () => { + expect( + sanitize("tool:fail", { + tool: "gesture-tap", + platform: "ios", + duration_ms: 1, + error_code: "ANDROID_ADB_COMMAND_FAILED", + failure_stage: "android_adb_command", + failure_area: "tool_server", + error_kind: "subprocess", + failure_command: "adb", + failure_exit_code: 1, + failure_signal: "SIGKILL", + failure_spawn_code: "ENOENT", + network_failure: "connection_refused", + }) + ).toEqual({ + tool: "gesture-tap", + platform: "ios", + duration_ms: 1, + error_code: "ANDROID_ADB_COMMAND_FAILED", + failure_stage: "android_adb_command", + failure_area: "tool_server", + error_kind: "subprocess", + failure_command: "adb", + failure_exit_code: 1, + failure_signal: "SIGKILL", + failure_spawn_code: "ENOENT", + network_failure: "connection_refused", + }); + }); + + it("rejects unsafe subprocess and network metadata", () => { + expect( + sanitize("tool:fail", { + tool: "gesture-tap", + platform: "ios", + duration_ms: 1, + failure_command: "adb -s /Users/alice/device shell secret", + failure_exit_code: 999, + failure_signal: "SIGUSR1", + failure_spawn_code: "ESECRET", + network_failure: "https://internal.example", + }) + ).toEqual({ + tool: "gesture-tap", + platform: "ios", + duration_ms: 1, + }); + }); + + it("accepts every centrally registered failure code", () => { + for (const errorCode of Object.values(FAILURE_CODES)) { + expect( + sanitize("tool:fail", { + tool: "gesture-tap", + platform: "ios", + duration_ms: 1, + error_code: errorCode, + }) + ).toMatchObject({ error_code: errorCode }); + } + }); + + it("rejects non-static-looking failure signals", () => { + expect( + sanitize("tool:fail", { + tool: "gesture-tap", + platform: "ios", + duration_ms: 1, + error_code: "ENOENT /Users/alice/.ssh/id_rsa", + failure_stage: "../secret", + failure_area: "laptop", + error_kind: "password", + }) + ).toEqual({ + tool: "gesture-tap", + platform: "ios", + duration_ms: 1, + }); + }); + + it("rejects static-looking but unregistered failure codes", () => { + expect( + sanitize("tool:fail", { + tool: "gesture-tap", + platform: "ios", + duration_ms: 1, + error_code: "SOME_NEW_UNREGISTERED_FAILURE", + }) + ).toEqual({ + tool: "gesture-tap", + platform: "ios", + duration_ms: 1, + }); + }); + + it("allows static failure signal fields on CLI run failures", () => { + expect( + sanitize("cli:run_fail", { + tool: "gesture-tap", + duration_ms: 1, + error_code: "CLI_RUN_ARGS_JSON_INVALID", + failure_stage: "cli_run_parse_raw_args", + failure_area: "cli", + error_kind: "validation", + }) + ).toEqual({ + tool: "gesture-tap", + duration_ms: 1, + error_code: "CLI_RUN_ARGS_JSON_INVALID", + failure_stage: "cli_run_parse_raw_args", + failure_area: "cli", + error_kind: "validation", + }); + }); + + it("drops server-only and unsafe fields from CLI run failures", () => { + expect( + sanitize("cli:run_fail", { + tool: "Gesture-Tap!", + duration_ms: 1, + tool_invocation_id: "11111111-1111-4111-8111-111111111111", + platform: "ios", + error_message: "ENOENT /Users/alice/.ssh/id_rsa", + stack: "Error: ENOENT\n at /Users/alice/project/app.ts:1", + error_code: "CLI_RUN_TOOL_CALL_FAILED", + }) + ).toEqual({ + duration_ms: 1, + error_code: "CLI_RUN_TOOL_CALL_FAILED", + }); + }); }); describe("number validator", () => { @@ -148,6 +306,29 @@ describe("sanitize", () => { }); }); + it("allows static failure signal fields on crash stop events", () => { + expect( + sanitize("toolserver:stop", { + reason: "crash", + uptime_ms: 10, + total_tool_calls: 2, + error_code: "TOOLSERVER_UNCAUGHT_EXCEPTION", + failure_stage: "toolserver_uncaught_exception", + failure_area: "tool_server", + error_kind: "crash", + stack: "Error: secret at /Users/alice/project/app.ts:1", + }) + ).toEqual({ + reason: "crash", + uptime_ms: 10, + total_tool_calls: 2, + error_code: "TOOLSERVER_UNCAUGHT_EXCEPTION", + failure_stage: "toolserver_uncaught_exception", + failure_area: "tool_server", + error_kind: "crash", + }); + }); + describe("package action telemetry", () => { it("accepts the requested package-action enum set", () => { for (const action of [ diff --git a/packages/tool-server/src/blueprints/android-devtools.ts b/packages/tool-server/src/blueprints/android-devtools.ts index 0093a642..744cfc83 100644 --- a/packages/tool-server/src/blueprints/android-devtools.ts +++ b/packages/tool-server/src/blueprints/android-devtools.ts @@ -2,6 +2,9 @@ import { spawn, ChildProcess } from "node:child_process"; import * as readline from "node:readline"; import { TypedEventEmitter, + FAILURE_CODES, + FailureError, + subprocessFailureMetadata, type DeviceInfo, type ServiceBlueprint, type ServiceInstance, @@ -66,8 +69,14 @@ async function spawnHelper(serial: string): Promise { const manifest = helperManifest(); const adbPath = await resolveAndroidBinary("adb"); if (!adbPath) { - throw new Error( - "`adb` not found on PATH or under `$ANDROID_HOME/platform-tools` while spawning android-devtools helper." + throw new FailureError( + "`adb` not found on PATH or under `$ANDROID_HOME/platform-tools` while spawning android-devtools helper.", + { + error_code: FAILURE_CODES.ANDROID_DEVTOOLS_ADB_NOT_FOUND, + failure_stage: "android_devtools_spawn_helper", + failure_area: "tool_server", + error_kind: "dependency_missing", + } ); } @@ -107,7 +116,12 @@ async function spawnHelper(serial: string): Promise { }); const lpMatch = ADB_FORWARD_PORT_MARKER.exec(stdout.trim()); if (!lpMatch) { - throw new Error(`adb forward returned unexpected output: ${stdout.trim()}`); + throw new FailureError(`adb forward returned unexpected output: ${stdout.trim()}`, { + error_code: FAILURE_CODES.ANDROID_DEVTOOLS_ADB_FORWARD_UNEXPECTED, + failure_stage: "android_devtools_adb_forward", + failure_area: "tool_server", + error_kind: "subprocess", + }); } localPort = parseInt(lpMatch[1]!, 10); settle(() => resolve({ proc, devicePort: devicePort!, localPort: localPort! })); @@ -128,20 +142,60 @@ async function spawnHelper(serial: string): Promise { const detail = stderrBuf.trim() ? ` stderr=${stderrBuf.trim().slice(0, 400)}` : ""; settle(() => reject( - new Error( - `am instrument exited before becoming ready (code=${code} signal=${signal}).${detail}` + new FailureError( + `am instrument exited before becoming ready (code=${code} signal=${signal}).${detail}`, + { + error_code: FAILURE_CODES.ANDROID_DEVTOOLS_HELPER_EXITED_BEFORE_READY, + failure_stage: "android_devtools_helper_ready", + failure_area: "tool_server", + error_kind: "subprocess", + failure_command: "android_devtools", + ...(typeof code === "number" ? { failure_exit_code: code } : {}), + ...(signal === "SIGABRT" || + signal === "SIGHUP" || + signal === "SIGINT" || + signal === "SIGKILL" || + signal === "SIGQUIT" || + signal === "SIGTERM" + ? { failure_signal: signal } + : {}), + } ) ) ); }); proc.on("error", (err) => { - settle(() => reject(err)); + settle(() => + reject( + new FailureError( + "android-devtools helper process error.", + { + error_code: FAILURE_CODES.ANDROID_DEVTOOLS_HELPER_PROCESS_ERROR, + failure_stage: "android_devtools_helper_process", + failure_area: "tool_server", + error_kind: "subprocess", + ...subprocessFailureMetadata(err, "android_devtools"), + }, + { cause: err } + ) + ) + ); }); const timer = setTimeout(() => { settle( - () => reject(new Error("Timed out waiting for android-devtools helper to become ready")), + () => + reject( + new FailureError("Timed out waiting for android-devtools helper to become ready", { + error_code: FAILURE_CODES.ANDROID_DEVTOOLS_HELPER_READY_TIMEOUT, + failure_stage: "android_devtools_helper_ready", + failure_area: "tool_server", + error_kind: "timeout", + failure_command: "android_devtools", + failure_signal: "SIGTERM", + }) + ), () => proc.kill() ); }, READY_TIMEOUT_MS); @@ -167,21 +221,39 @@ export const androidDevtoolsBlueprint: ServiceBlueprint { if (settled) return; settled = true; cleanup(); - reject(err); + reject( + new FailureError( + err instanceof Error ? err.message : String(err), + { + error_code: FAILURE_CODES.AX_DAEMON_PROCESS_ERROR, + failure_stage: "ax_service_spawn_process", + failure_area: "tool_server", + error_kind: "subprocess", + }, + { cause: err instanceof Error ? err : new Error(String(err)) } + ) + ); }; const timer = setTimeout(() => { if (settled) return; settled = true; cleanup(); - reject(new Error("Timed out waiting for ax-service to connect")); + reject( + new FailureError("Timed out waiting for ax-service to connect", { + error_code: FAILURE_CODES.AX_DAEMON_READY_TIMEOUT, + failure_stage: "ax_service_spawn_ready", + failure_area: "tool_server", + error_kind: "timeout", + }) + ); }, timeoutMs); const cleanup = () => { @@ -297,16 +324,28 @@ export const axServiceBlueprint: ServiceBlueprint = { async factory(_deps, _payload, options) { const opts = options as unknown as AxServiceFactoryOptions | undefined; if (!opts?.device) { - throw new Error( + throw new FailureError( `${AX_SERVICE_NAMESPACE}.factory requires a resolved DeviceInfo via options.device. ` + - `Use axServiceRef(device) when registering the service ref.` + `Use axServiceRef(device) when registering the service ref.`, + { + error_code: FAILURE_CODES.AX_FACTORY_OPTIONS_MISSING, + failure_stage: "ax_service_factory_options", + failure_area: "tool_server", + error_kind: "validation", + } ); } const { device } = opts; if (device.platform !== "ios") { - throw new Error( - `${AX_SERVICE_NAMESPACE} is iOS-only. The target '${device.id}' classifies as Android — describe falls back to uiautomator on Android, which does not need this service.` + throw new FailureError( + `${AX_SERVICE_NAMESPACE} is iOS-only. The target '${device.id}' classifies as Android — describe falls back to uiautomator on Android, which does not need this service.`, + { + error_code: FAILURE_CODES.AX_WRONG_PLATFORM, + failure_stage: "ax_service_factory_platform", + failure_area: "tool_server", + error_kind: "validation", + } ); } // Reject before spawning. An undefined `device.id` slips through when an @@ -314,8 +353,14 @@ export const axServiceBlueprint: ServiceBlueprint = { // schema. Without this guard `getSocketPath(undefined).slice` would crash // and `udid.slice` in the stderr handler below would later be fatal. if (typeof device.id !== "string" || device.id.length === 0) { - throw new Error( - `${AX_SERVICE_NAMESPACE}.factory requires a non-empty device.id; got ${JSON.stringify(device.id)}.` + throw new FailureError( + `${AX_SERVICE_NAMESPACE}.factory requires a non-empty device.id; got ${JSON.stringify(device.id)}.`, + { + error_code: FAILURE_CODES.AX_DEVICE_ID_INVALID, + failure_stage: "ax_service_factory_device_id", + failure_area: "tool_server", + error_kind: "validation", + } ); } @@ -366,7 +411,15 @@ export const axServiceBlueprint: ServiceBlueprint = { clearTimeout(pending.timer); if (msg.error !== undefined && msg.error !== null) { pending.reject( - new Error(typeof msg.error === "string" ? msg.error : JSON.stringify(msg.error)) + new FailureError( + typeof msg.error === "string" ? msg.error : JSON.stringify(msg.error), + { + error_code: FAILURE_CODES.AX_QUERY_FAILED, + failure_stage: "ax_service_query_rpc", + failure_area: "tool_server", + error_kind: "unknown", + } + ) ); } else { pending.resolve(msg.result); @@ -378,7 +431,12 @@ export const axServiceBlueprint: ServiceBlueprint = { if (daemonSocket === socket) { daemonSocket = null; if (!disposed) { - const err = new Error("ax-service daemon disconnected"); + const err = new FailureError("ax-service daemon disconnected", { + error_code: FAILURE_CODES.AX_DAEMON_PROCESS_ERROR, + failure_stage: "ax_service_socket_close", + failure_area: "tool_server", + error_kind: "subprocess", + }); failPending(err); events.emit("terminated", err); } @@ -394,14 +452,29 @@ export const axServiceBlueprint: ServiceBlueprint = { proc.on("exit", (code) => { if (disposed) return; - const err = new Error(`ax-service exited with code ${code}`); + const err = new FailureError(`ax-service exited with code ${code}`, { + error_code: FAILURE_CODES.AX_DAEMON_PROCESS_ERROR, + failure_stage: "ax_service_process_exit", + failure_area: "tool_server", + error_kind: "subprocess", + }); failPending(err); events.emit("terminated", err); }); proc.on("error", (err) => { if (disposed) return; - failPending(err); - events.emit("terminated", err); + const error = new FailureError( + err instanceof Error ? err.message : String(err), + { + error_code: FAILURE_CODES.AX_DAEMON_PROCESS_ERROR, + failure_stage: "ax_service_spawn_process", + failure_area: "tool_server", + error_kind: "subprocess", + }, + { cause: err instanceof Error ? err : new Error(String(err)) } + ); + failPending(error); + events.emit("terminated", error); }); try { @@ -421,14 +494,28 @@ export const axServiceBlueprint: ServiceBlueprint = { function query(command: string, timeoutMs = 5000): Promise { return new Promise((resolve, reject) => { if (!daemonSocket || daemonSocket.destroyed) { - reject(new Error("ax-service not connected")); + reject( + new FailureError("ax-service not connected", { + error_code: FAILURE_CODES.AX_QUERY_FAILED, + failure_stage: "ax_service_query_socket", + failure_area: "tool_server", + error_kind: "subprocess", + }) + ); return; } const id = nextRpcId++; const timer = setTimeout(() => { if (pendingRpc.has(id)) { pendingRpc.delete(id); - reject(new Error(`ax-service query timed out: ${command}`)); + reject( + new FailureError(`ax-service query timed out: ${command}`, { + error_code: FAILURE_CODES.AX_QUERY_TIMEOUT, + failure_stage: "ax_service_query_socket", + failure_area: "tool_server", + error_kind: "timeout", + }) + ); } }, timeoutMs); pendingRpc.set(id, { resolve, reject, timer }); @@ -440,7 +527,17 @@ export const axServiceBlueprint: ServiceBlueprint = { degraded: !entitlementBypassActive, async describe(): Promise { - const result = (await query("describe", 10_000)) as AXDescribeResponse; + const result = (await query("describe", 10_000)) as AXDescribeResponse & { + error?: string; + }; + if (result.error) { + throw new FailureError(`ax-service describe error: ${result.error}`, { + error_code: FAILURE_CODES.AX_DESCRIBE_ERROR, + failure_stage: "ax_service_describe", + failure_area: "tool_server", + error_kind: "unknown", + }); + } return { alertVisible: result.alertVisible ?? false, screenFrame: result.screenFrame, diff --git a/packages/tool-server/src/blueprints/js-runtime-debugger.ts b/packages/tool-server/src/blueprints/js-runtime-debugger.ts index 27a4596f..3a1231ed 100644 --- a/packages/tool-server/src/blueprints/js-runtime-debugger.ts +++ b/packages/tool-server/src/blueprints/js-runtime-debugger.ts @@ -1,4 +1,10 @@ -import { TypedEventEmitter, type ServiceBlueprint, type ServiceEvents } from "@argent/registry"; +import { + FAILURE_CODES, + FailureError, + TypedEventEmitter, + type ServiceBlueprint, + type ServiceEvents, +} from "@argent/registry"; import { discoverMetro } from "../utils/debugger/discovery"; import { selectTarget } from "../utils/debugger/target-selection"; import { CDPClient, type ConsoleAPICalledParams } from "../utils/debugger/cdp-client"; @@ -67,7 +73,14 @@ function createConsoleLogServer( server.listen(0, "127.0.0.1", () => { const addr = server.address(); if (!addr || typeof addr === "string") { - reject(new Error("Failed to bind console log server")); + reject( + new FailureError("Failed to bind console log server", { + error_code: FAILURE_CODES.JS_RUNTIME_CONSOLE_SERVER_BIND_FAILED, + failure_stage: "js_runtime_console_server_bind", + failure_area: "tool_server", + error_kind: "network", + }) + ); return; } const url = `ws://127.0.0.1:${addr.port}`; @@ -110,15 +123,33 @@ export const jsRuntimeDebuggerBlueprint: ServiceBlueprint(); cdp.events.on("disconnected", (error) => { - events.emit("terminated", error ?? new Error("CDP disconnected")); + events.emit( + "terminated", + error ?? + new FailureError("CDP disconnected", { + error_code: FAILURE_CODES.JS_RUNTIME_CDP_DISCONNECTED, + failure_stage: "js_runtime_debugger_cdp_lifecycle", + failure_area: "tool_server", + error_kind: "network", + }) + ); }); return { diff --git a/packages/tool-server/src/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index 1b009111..26da3dd2 100644 --- a/packages/tool-server/src/blueprints/native-devtools.ts +++ b/packages/tool-server/src/blueprints/native-devtools.ts @@ -6,6 +6,8 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { TypedEventEmitter, + FAILURE_CODES, + FailureError, type DeviceInfo, type ServiceBlueprint, type ServiceEvents, @@ -345,17 +347,29 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint { if (pendingRpc.has(id)) { pendingRpc.delete(id); - reject(new Error(`ViewInspector RPC timed out: ${method}`)); + reject( + new FailureError(`ViewInspector RPC timed out: ${method}`, { + error_code: FAILURE_CODES.NATIVE_DEVTOOLS_RPC_TIMEOUT, + failure_stage: "native_devtools_rpc_request", + failure_area: "tool_server", + error_kind: "timeout", + }) + ); } }, 5000); }); @@ -521,8 +547,16 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint(); cdp.events.on("disconnected", (error) => { - events.emit("terminated", error ?? new Error("CDP disconnected")); + events.emit( + "terminated", + error ?? + new FailureError("CDP disconnected", { + error_code: FAILURE_CODES.NETWORK_INSPECTOR_CDP_DISCONNECTED, + failure_stage: "network_inspector_cdp_lifecycle", + failure_area: "tool_server", + error_kind: "network", + }) + ); }); return { diff --git a/packages/tool-server/src/blueprints/react-profiler-session.ts b/packages/tool-server/src/blueprints/react-profiler-session.ts index 2f29c8f7..56c9cc74 100644 --- a/packages/tool-server/src/blueprints/react-profiler-session.ts +++ b/packages/tool-server/src/blueprints/react-profiler-session.ts @@ -1,4 +1,10 @@ -import { TypedEventEmitter, type ServiceBlueprint, type ServiceEvents } from "@argent/registry"; +import { + FAILURE_CODES, + FailureError, + TypedEventEmitter, + type ServiceBlueprint, + type ServiceEvents, +} from "@argent/registry"; import type { CDPClient } from "../utils/debugger/cdp-client"; import type { JsRuntimeDebuggerApi } from "./js-runtime-debugger"; import { FIBER_ROOT_TRACKER_SCRIPT } from "../utils/react-profiler/scripts"; @@ -60,11 +66,24 @@ export const reactProfilerSessionBlueprint: ServiceBlueprint {}; const warnOnError = (label: string) => (err: unknown) => { @@ -161,7 +180,16 @@ export const reactProfilerSessionBlueprint: ServiceBlueprint { - settle(() => reject(new Error(`simulator-server exited with code before becoming ready`))); + proc.on("exit", (code, signal) => { + settle(() => + reject( + new FailureError("simulator-server exited with code before becoming ready", { + error_code: FAILURE_CODES.SIMULATOR_SERVER_READY_EXITED, + failure_stage: "simulator_server_spawn_ready", + failure_area: "tool_server", + error_kind: "subprocess", + failure_command: "simulator_server", + ...(typeof code === "number" ? { failure_exit_code: code } : {}), + ...(signal === "SIGABRT" || + signal === "SIGHUP" || + signal === "SIGINT" || + signal === "SIGKILL" || + signal === "SIGQUIT" || + signal === "SIGTERM" + ? { failure_signal: signal } + : {}), + }) + ) + ); }); proc.on("error", (err) => { - settle(() => reject(err)); + settle(() => + reject( + new FailureError( + err instanceof Error ? err.message : String(err), + { + error_code: FAILURE_CODES.SIMULATOR_SERVER_PROCESS_ERROR, + failure_stage: "simulator_server_spawn_process", + failure_area: "tool_server", + error_kind: "subprocess", + ...subprocessFailureMetadata(err, "simulator_server"), + }, + { cause: err instanceof Error ? err : new Error(String(err)) } + ) + ) + ); }); const timer = setTimeout(() => { settle( - () => reject(new Error("Timed out waiting for simulator-server to become ready")), + () => + reject( + new FailureError("Timed out waiting for simulator-server to become ready", { + error_code: FAILURE_CODES.SIMULATOR_SERVER_READY_TIMEOUT, + failure_stage: "simulator_server_spawn_ready", + failure_area: "tool_server", + error_kind: "timeout", + failure_command: "simulator_server", + failure_signal: "SIGKILL", + }) + ), () => proc.kill() ); }, READY_TIMEOUT_MS); @@ -161,16 +207,28 @@ export const simulatorServerBlueprint: ServiceBlueprint(); proc.on("exit", (code) => { - events.emit("terminated", new Error(`Process exited with code ${code}`)); + events.emit( + "terminated", + new FailureError(`Process exited with code ${code}`, { + error_code: FAILURE_CODES.SIMULATOR_SERVER_TERMINATED, + failure_stage: "simulator_server_process_exit", + failure_area: "tool_server", + error_kind: "subprocess", + }) + ); }); proc.on("error", (err) => { events.emit("terminated", err); diff --git a/packages/tool-server/src/http.ts b/packages/tool-server/src/http.ts index 2d86db56..86f92dcd 100644 --- a/packages/tool-server/src/http.ts +++ b/packages/tool-server/src/http.ts @@ -1,6 +1,6 @@ import express, { Request, Response } from "express"; import { randomUUID } from "node:crypto"; -import type { Registry } from "@argent/registry"; +import { FAILURE_CODES, type FailureSignal, type Registry } from "@argent/registry"; import { ToolNotFoundError } from "@argent/registry"; import { createIdleTimer } from "./utils/idle-timer"; import { DependencyMissingError, ensureDeps } from "./utils/check-deps"; @@ -39,10 +39,17 @@ function extractBearerToken(authHeader: string | undefined): string | null { } function findDependencyMissing(err: unknown): DependencyMissingError | null { + return findErrorInCauseChain(err, DependencyMissingError); +} + +function findErrorInCauseChain( + err: unknown, + ctor: new (...args: never[]) => T +): T | null { let current: unknown = err; // Bounded to avoid pathological cycles; in practice the chain is ≤ 2 links. for (let depth = 0; depth < 8 && current instanceof Error; depth++) { - if (current instanceof DependencyMissingError) return current; + if (current instanceof ctor) return current; current = current.cause; } return null; @@ -57,6 +64,19 @@ function extractDeviceArg(data: unknown): string | null { } type InvocationMeta = { platform?: "ios" | "android" }; +// Only coarse platform context is retained for failure telemetry. The raw +// device id (UDID / serial) is used transiently to infer platform and never +// stored or forwarded. +type HttpFailureMeta = { platform?: "ios" | "android" }; + +function inferPlatform(deviceId: string | null): HttpFailureMeta["platform"] | null { + if (!deviceId) return null; + try { + return resolveDevice(deviceId).platform; + } catch { + return null; + } +} function extractInvocationMeta(hasCapability: boolean, data: unknown): InvocationMeta | null { if (!hasCapability || !data || typeof data !== "object") return null; @@ -89,6 +109,13 @@ export interface HttpAppOptions { bindHost?: string; /** Optional telemetry hook for per-invocation platform/device metadata. */ recordInvocation?: (toolInvocationId: string, meta: InvocationMeta) => () => void; + /** Optional telemetry hook for HTTP failures that happen before registry invocation. */ + recordFailure?: ( + toolId: string, + meta: HttpFailureMeta, + signal: FailureSignal, + durationMs: number + ) => void; } export interface HttpAppHandle { @@ -267,9 +294,33 @@ export function createHttpApp(registry: Registry, options?: HttpAppOptions): Htt }, async (req: Request, res: Response) => { const name = req.params.name!; + const requestStartedAt = performance.now(); + + const emitHttpFailure = ( + signal: FailureSignal, + parsedDataForMeta: unknown = req.body + ): void => { + if (!options?.recordFailure) return; + const failedDeviceArg = extractDeviceArg(parsedDataForMeta); + const platform = inferPlatform(failedDeviceArg); + options.recordFailure( + name, + { + ...(platform ? { platform } : {}), + }, + signal, + performance.now() - requestStartedAt + ); + }; const def = registry.getTool(name); if (!def) { + emitHttpFailure({ + error_code: FAILURE_CODES.HTTP_TOOL_NOT_FOUND, + failure_stage: "http_lookup_tool", + failure_area: "http", + error_kind: "not_found", + }); res.status(404).json({ error: `Tool "${name}" not found` }); return; } @@ -278,6 +329,15 @@ export function createHttpApp(registry: Registry, options?: HttpAppOptions): Htt if (def.zodSchema) { const parseResult = def.zodSchema.safeParse(req.body); if (!parseResult.success) { + emitHttpFailure( + { + error_code: FAILURE_CODES.HTTP_ZOD_VALIDATION_FAILED, + failure_stage: "http_zod_validation", + failure_area: "http", + error_kind: "validation", + }, + req.body + ); res.status(400).json({ error: parseResult.error.message }); return; } @@ -302,10 +362,29 @@ export function createHttpApp(registry: Registry, options?: HttpAppOptions): Htt assertSupported(def.id, def.capability, device); } catch (err) { if (err instanceof UnsupportedOperationError) { + emitHttpFailure( + { + error_code: FAILURE_CODES.HTTP_CAPABILITY_UNSUPPORTED_OPERATION, + failure_stage: "http_capability_gate", + failure_area: "http", + error_kind: "unsupported", + }, + parsedData + ); res.status(400).json({ error: err.message }); return; } - throw err; + emitHttpFailure( + { + error_code: FAILURE_CODES.HTTP_DEVICE_RESOLUTION_FAILED, + failure_stage: "http_capability_device_resolution", + failure_area: "http", + error_kind: "validation", + }, + parsedData + ); + res.status(400).json({ error: err instanceof Error ? err.message : String(err) }); + return; } } @@ -320,6 +399,15 @@ export function createHttpApp(registry: Registry, options?: HttpAppOptions): Htt await ensureDeps(def.requires); } catch (err) { if (err instanceof DependencyMissingError) { + emitHttpFailure( + { + error_code: FAILURE_CODES.HTTP_DEPENDENCY_PREFLIGHT_MISSING, + failure_stage: "http_dependency_preflight", + failure_area: "http", + error_kind: "dependency_missing", + }, + parsedData + ); res.status(424).json({ error: err.message, missing: err.missing }); return; } @@ -332,8 +420,7 @@ export function createHttpApp(registry: Registry, options?: HttpAppOptions): Htt if (!res.writableFinished) controller.abort(); }); - // The HTTP layer owns the invocation id so request metadata can be - // correlated without relying on same-tool FIFO ordering. + // Hashing happens in the telemetry listener, not in the HTTP layer. const toolInvocationId = randomUUID(); let releaseInvocationMeta: (() => void) | undefined; if (options?.recordInvocation) { @@ -381,16 +468,18 @@ export function createHttpApp(registry: Registry, options?: HttpAppOptions): Htt res.status(424).json({ error: depErr.message, missing: depErr.missing }); return; } - if (err instanceof UnsupportedOperationError) { - res.status(400).json({ error: err.message }); + const unsupportedErr = findErrorInCauseChain(err, UnsupportedOperationError); + if (unsupportedErr) { + res.status(400).json({ error: unsupportedErr.message }); return; } - if (err instanceof NotImplementedOnPlatformError) { + const notImplementedErr = findErrorInCauseChain(err, NotImplementedOnPlatformError); + if (notImplementedErr) { res.status(501).json({ - error: err.message, - toolId: err.toolId, - platform: err.platform, - hint: err.hint, + error: notImplementedErr.message, + toolId: notImplementedErr.toolId, + platform: notImplementedErr.platform, + hint: notImplementedErr.hint, }); return; } diff --git a/packages/tool-server/src/index.ts b/packages/tool-server/src/index.ts index 816a8019..1d2fd81b 100644 --- a/packages/tool-server/src/index.ts +++ b/packages/tool-server/src/index.ts @@ -1,4 +1,4 @@ -import { attachRegistryLogger } from "@argent/registry"; +import { FAILURE_CODES, attachRegistryLogger, type FailureSignal } from "@argent/registry"; import { init as telemetryInit, attachRegistryTelemetry, @@ -55,6 +55,18 @@ export function start(): void { function crashShutdown(label: string, detail: string): void { process.stderr.write(`[tool-server] ${label}: ${detail}\n`); shutdownReason = "crash"; + shutdownFailureSignal = { + error_code: + label === "Unhandled rejection" + ? FAILURE_CODES.TOOLSERVER_UNHANDLED_REJECTION + : FAILURE_CODES.TOOLSERVER_UNCAUGHT_EXCEPTION, + failure_stage: + label === "Unhandled rejection" + ? "toolserver_unhandled_rejection" + : "toolserver_uncaught_exception", + failure_area: "tool_server", + error_kind: "crash", + }; setTimeout(() => process.exit(1), PROCESS_TIMEOUT_MS); if (shutdown) { shutdown(1).catch(() => process.exit(1)); @@ -91,6 +103,7 @@ export function start(): void { const telemetryHandle = attachRegistryTelemetry(registry); const serverStartedAt = Date.now(); let shutdownReason: "idle" | "signal" | "crash" = "signal"; + let shutdownFailureSignal: FailureSignal | null = null; const updateChecker = startUpdateChecker(); @@ -114,6 +127,7 @@ export function start(): void { reason: shutdownReason, uptime_ms: Date.now() - serverStartedAt, total_tool_calls: telemetryHandle.getTotalToolCalls(), + ...(shutdownFailureSignal ?? {}), }); telemetryHandle.detach(); await telemetryShutdown(1500); @@ -139,6 +153,14 @@ export function start(): void { onShutdown: shutdown, bindHost: HOST, recordInvocation: telemetryHandle.recordInvocation, + recordFailure: (toolId, meta, signal, durationMs) => { + telemetryTrack("tool:fail", { + tool: toolId, + ...(meta.platform ? { platform: meta.platform } : {}), + duration_ms: durationMs, + ...signal, + }); + }, }); // Block advertising readiness until the first watcher poll completes — this diff --git a/packages/tool-server/src/tools/debugger/debugger-reload-metro.ts b/packages/tool-server/src/tools/debugger/debugger-reload-metro.ts index f134504b..0f0a521e 100644 --- a/packages/tool-server/src/tools/debugger/debugger-reload-metro.ts +++ b/packages/tool-server/src/tools/debugger/debugger-reload-metro.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import type { JsRuntimeDebuggerApi } from "../../blueprints/js-runtime-debugger"; import { DISABLE_LOGBOX_SCRIPT } from "../../utils/debugger/scripts/disable-logbox"; @@ -61,8 +61,14 @@ Use when you want to apply code changes or reset JS state. Returns { reloaded, p method: "POST", }); if (!res.ok) { - throw new Error( - `Failed to reload: CDP Page.reload unsupported and Metro HTTP /reload returned ${res.status} ${res.statusText}.` + throw new FailureError( + `Failed to reload: CDP Page.reload unsupported and Metro HTTP /reload returned ${res.status} ${res.statusText}.`, + { + error_code: FAILURE_CODES.DEBUGGER_RELOAD_FAILED, + failure_stage: "debugger_reload_metro", + failure_area: "tool_server", + error_kind: "network", + } ); } disableLogBox(); diff --git a/packages/tool-server/src/tools/describe/platforms/android/uiautomator-parser.ts b/packages/tool-server/src/tools/describe/platforms/android/uiautomator-parser.ts index 27107186..541bfaab 100644 --- a/packages/tool-server/src/tools/describe/platforms/android/uiautomator-parser.ts +++ b/packages/tool-server/src/tools/describe/platforms/android/uiautomator-parser.ts @@ -1,3 +1,4 @@ +import { FAILURE_CODES, FailureError } from "@argent/registry"; import type { DescribeFrame, DescribeNode } from "../../contract"; interface ParsedXmlNode { @@ -619,7 +620,12 @@ export function parseUiAutomatorDump( if (xmlEnd !== -1) xml = xml.slice(0, xmlEnd + "".length); const root = parseUiAutomatorXml(xml); if (!root) { - throw new Error("Failed to parse uiautomator dump output"); + throw new FailureError("Failed to parse uiautomator dump output", { + error_code: FAILURE_CODES.ANDROID_UIAUTOMATOR_PARSE_FAILED, + failure_stage: "android_uiautomator_parse_dump", + failure_area: "tool_server", + error_kind: "subprocess", + }); } const includeSystem = options.includeSystem === true; const opts: PruneOptions = { screenW, screenH, includeSystem }; diff --git a/packages/tool-server/src/tools/devices/boot-device.ts b/packages/tool-server/src/tools/devices/boot-device.ts index f3db9280..f6763fae 100644 --- a/packages/tool-server/src/tools/devices/boot-device.ts +++ b/packages/tool-server/src/tools/devices/boot-device.ts @@ -1,18 +1,20 @@ import { execFile, spawn } from "node:child_process"; import { promisify } from "node:util"; import { z } from "zod"; -import type { Registry, ToolCapability, ToolDefinition } from "@argent/registry"; +import { + FAILURE_CODES, + FailureError, + type Registry, + type ToolCapability, + type ToolDefinition, +} from "@argent/registry"; import { buildInitFailedResult, nativeDevtoolsRef, type NativeDevtoolsApi, type NativeDevtoolsInitFailedResult, } from "../../blueprints/native-devtools"; -import { - ensureAutomationEnabled, - isEntitlementBypassActive, - setAccessibilityPrefsPreBoot, -} from "../../blueprints/ax-service"; +import { ensureAutomationEnabled, setAccessibilityPrefsPreBoot } from "../../blueprints/ax-service"; import { adbShell, checkSnapshotLoadable, @@ -152,9 +154,15 @@ function selectGpuMode(): string { if (override && override.trim()) { const value = override.trim(); if (!VALID_GPU_MODES.has(value)) { - throw new Error( + throw new FailureError( `ARGENT_EMULATOR_GPU_MODE=${JSON.stringify(value)} is not a known emulator -gpu value. ` + - `Valid values: ${[...VALID_GPU_MODES].join(", ")}.` + `Valid values: ${[...VALID_GPU_MODES].join(", ")}.`, + { + error_code: FAILURE_CODES.BOOT_ANDROID_GPU_MODE_INVALID, + failure_stage: "boot_android_gpu_mode", + failure_area: "tool_server", + error_kind: "validation", + } ); } return value; @@ -300,9 +308,15 @@ async function assertScreencapAlive( await new Promise((r) => setTimeout(r, 1_500)); } await killEmulatorQuietly(serial); - throw new Error( + throw new FailureError( `hot-boot composite did not restore within ${budgetMs / 1000}s — \`screencap\` last returned ` + - `${JSON.stringify(lastReading ?? "no probe response")}. Falling back to cold boot so screenshots are usable.` + `${JSON.stringify(lastReading ?? "no probe response")}. Falling back to cold boot so screenshots are usable.`, + { + error_code: FAILURE_CODES.BOOT_ANDROID_HOT_BOOT_FRAME_UNUSABLE, + failure_stage: "boot_android_hot_boot_frame", + failure_area: "tool_server", + error_kind: "timeout", + } ); } @@ -342,9 +356,15 @@ async function awaitFirstRealFrame(serial: string, timeoutMs: number): Promise setTimeout(r, 1_500)); } - throw new Error( + throw new FailureError( `SurfaceFlinger did not composite a real frame within ${timeoutMs / 1000}s of boot_completed ` + - `(${lastError ?? "no probe response"}). The emulator booted but every screenshot would be all-black.` + `(${lastError ?? "no probe response"}). The emulator booted but every screenshot would be all-black.`, + { + error_code: FAILURE_CODES.BOOT_ANDROID_FIRST_FRAME_TIMEOUT, + failure_stage: "boot_android_first_real_frame", + failure_area: "tool_server", + error_kind: "timeout", + } ); } @@ -382,9 +402,15 @@ async function bootIos( // Catch the non-darwin case before `ensureDep("xcrun")` so a Linux user // gets "iOS requires macOS" rather than a misleading "install xcode-select". if (process.platform !== "darwin") { - throw new Error( + throw new FailureError( `iOS Simulator is unavailable on ${process.platform}: it requires a macOS host. ` + - `Pass \`avdName\` (Android) instead of \`udid\` (iOS) to boot a device from this host.` + `Pass \`avdName\` (Android) instead of \`udid\` (iOS) to boot a device from this host.`, + { + error_code: FAILURE_CODES.BOOT_IOS_UNSUPPORTED_HOST, + failure_stage: "boot_ios_host_platform", + failure_area: "tool_server", + error_kind: "unsupported", + } ); } await ensureDep("xcrun"); @@ -542,9 +568,15 @@ async function attemptBoot(params: { throw earlyExitError; } killDetachedEmulator(child); - throw new Error( + throw new FailureError( `Emulator "${params.avdName}" did not register within ${params.adbRegisterBudgetMs / 1000}s. ` + - `The emulator process has been terminated.` + `The emulator process has been terminated.`, + { + error_code: FAILURE_CODES.BOOT_ANDROID_ADB_REGISTER_TIMEOUT, + failure_stage: "boot_android_adb_register", + failure_area: "tool_server", + error_kind: "timeout", + } ); } @@ -631,9 +663,15 @@ async function attemptBoot(params: { // with nothing to fall back to. if (params.tearDownIfUnready) { await killEmulatorQuietly(serial, child); - throw new Error( + throw new FailureError( `PackageManager did not respond on ${serial} within ${Math.round(pmBudgetMs / 1000)}s ` + - `after boot_completed. Emulator has been terminated.` + `after boot_completed. Emulator has been terminated.`, + { + error_code: FAILURE_CODES.BOOT_ANDROID_PACKAGE_MANAGER_UNAVAILABLE, + failure_stage: "boot_android_package_manager", + failure_area: "tool_server", + error_kind: "timeout", + } ); } process.stderr.write( @@ -725,13 +763,25 @@ async function bootAndroidImpl(params: { // out the binary-missing case. const avds = await listAvds(); if (avds.length === 0) { - throw new Error( - "`emulator -list-avds` returned no AVDs. Create one via Android Studio or `avdmanager create avd`." + throw new FailureError( + "`emulator -list-avds` returned no AVDs. Create one via Android Studio or `avdmanager create avd`.", + { + error_code: FAILURE_CODES.BOOT_ANDROID_NO_AVDS, + failure_stage: "boot_android_avd_list", + failure_area: "tool_server", + error_kind: "not_found", + } ); } if (!avds.some((a) => a.name === params.avdName)) { - throw new Error( - `AVD "${params.avdName}" not found. Available: ${avds.map((a) => a.name).join(", ")}.` + throw new FailureError( + `AVD "${params.avdName}" not found. Available: ${avds.map((a) => a.name).join(", ")}.`, + { + error_code: FAILURE_CODES.BOOT_ANDROID_AVD_NOT_FOUND, + failure_stage: "boot_android_avd_lookup", + failure_area: "tool_server", + error_kind: "not_found", + } ); } @@ -740,10 +790,18 @@ async function bootAndroidImpl(params: { try { await runAdb(["version"], { timeoutMs: 5_000 }); } catch (err) { - throw new Error( + throw new FailureError( `\`adb\` is not available on PATH (${ err instanceof Error ? err.message : String(err) - }). Install Android SDK Platform Tools before booting an emulator.` + }). Install Android SDK Platform Tools before booting an emulator.`, + { + error_code: FAILURE_CODES.BOOT_ANDROID_ADB_UNAVAILABLE, + failure_stage: "boot_android_adb_version", + failure_area: "tool_server", + error_kind: "dependency_missing", + failure_command: "adb", + }, + { cause: err instanceof Error ? err : new Error(String(err)) } ); } @@ -927,9 +985,16 @@ async function bootAndroidImpl(params: { const suffix = hotBootFailureReason ? ` Hot-boot was not viable (${hotBootFailureReason}).` : ""; - throw new Error( + throw new FailureError( `${base} Emulator has been terminated so the next boot starts clean.` + - ` If this keeps happening, wipe the AVD with \`emulator -avd ${params.avdName} -wipe-data\`.${suffix}` + ` If this keeps happening, wipe the AVD with \`emulator -avd ${params.avdName} -wipe-data\`.${suffix}`, + { + error_code: FAILURE_CODES.BOOT_ANDROID_COLD_BOOT_FAILED, + failure_stage: "boot_android_cold_boot", + failure_area: "tool_server", + error_kind: "subprocess", + }, + { cause: err instanceof Error ? err : new Error(String(err)) } ); } @@ -1026,7 +1091,12 @@ Android boots take 2–10 minutes depending on machine and cold/warm state; the const hasUdid = Boolean(params.udid); const hasAvd = Boolean(params.avdName); if (hasUdid === hasAvd) { - throw new Error("Provide exactly one of `udid` (iOS) or `avdName` (Android)."); + throw new FailureError("Provide exactly one of `udid` (iOS) or `avdName` (Android).", { + error_code: FAILURE_CODES.BOOT_DEVICE_TARGET_SELECTION_INVALID, + failure_stage: "boot_device_target_selection", + failure_area: "tool_server", + error_kind: "validation", + }); } if (hasUdid) { return bootIos(params.udid!, registry, params.force); diff --git a/packages/tool-server/src/tools/flows/flow-utils.ts b/packages/tool-server/src/tools/flows/flow-utils.ts index 6afd73e6..b3b17f91 100644 --- a/packages/tool-server/src/tools/flows/flow-utils.ts +++ b/packages/tool-server/src/tools/flows/flow-utils.ts @@ -1,5 +1,6 @@ import * as path from "node:path"; import * as fs from "node:fs/promises"; +import { FAILURE_CODES, FailureError } from "@argent/registry"; import { stringify as yamlStringify, parse as yamlParse } from "yaml"; const FLOWS_DIR_NAME = path.join(".argent", "flows"); @@ -13,10 +14,16 @@ let activeProjectRoot: string | null = null; export function setActiveProjectRoot(root: string): void { if (!path.isAbsolute(root)) { - throw new Error( + throw new FailureError( `project_root must be an absolute path (got "${root}"). ` + `Pass the absolute path to the project root directory — the same cwd ` + - `the calling agent is working in.` + `the calling agent is working in.`, + { + error_code: FAILURE_CODES.FLOW_PROJECT_ROOT_REQUIRED, + failure_stage: "flow_project_root_set", + failure_area: "tool_server", + error_kind: "validation", + } ); } // Reject ".." segments: getFlowsDir()/getFlowPath() join the flows dir under @@ -31,8 +38,14 @@ export function setActiveProjectRoot(root: string): void { export function requireActiveProjectRoot(): string { if (!activeProjectRoot) { - throw new Error( - "No active project root. The calling flow tool must pass project_root before any path is resolved." + throw new FailureError( + "No active project root. The calling flow tool must pass project_root before any path is resolved.", + { + error_code: FAILURE_CODES.FLOW_PROJECT_ROOT_REQUIRED, + failure_stage: "flow_project_root_require", + failure_area: "tool_server", + error_kind: "validation", + } ); } return activeProjectRoot; @@ -80,7 +93,12 @@ export function getActiveFlowOrNull(): string | null { export function getActiveFlow(): string { if (!activeFlowName) { - throw new Error("No active flow. Call flow-start-recording first."); + throw new FailureError("No active flow. Call flow-start-recording first.", { + error_code: FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING, + failure_stage: "flow_active_recording_require", + failure_area: "tool_server", + error_kind: "validation", + }); } return activeFlowName; } @@ -158,13 +176,23 @@ export function parseFlow(content: string): FlowFile { !("steps" in parsed) || !Array.isArray(parsed.steps) ) { - throw new Error("Invalid flow file: expected an object with a steps array"); + throw new FailureError("Invalid flow file: expected an object with a steps array", { + error_code: FAILURE_CODES.FLOW_FILE_INVALID, + failure_stage: "flow_file_parse", + failure_area: "tool_server", + error_kind: "validation", + }); } const steps = parsed.steps.map((raw) => { if ("echo" in raw) return fromYamlStep(raw); if ("tool" in raw) return fromYamlStep(raw); - throw new Error(`Unrecognized flow entry: ${JSON.stringify(raw)}`); + throw new FailureError(`Unrecognized flow entry: ${JSON.stringify(raw)}`, { + error_code: FAILURE_CODES.FLOW_ENTRY_UNRECOGNIZED, + failure_stage: "flow_file_parse_step", + failure_area: "tool_server", + error_kind: "validation", + }); }); return { diff --git a/packages/tool-server/src/tools/launch-app/platforms/android.ts b/packages/tool-server/src/tools/launch-app/platforms/android.ts index 4c7ebe91..6907fba3 100644 --- a/packages/tool-server/src/tools/launch-app/platforms/android.ts +++ b/packages/tool-server/src/tools/launch-app/platforms/android.ts @@ -1,3 +1,4 @@ +import { FAILURE_CODES, FailureError } from "@argent/registry"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; import { adbShell, shellQuote } from "../../../utils/adb"; import type { LaunchAppAndroidServices, LaunchAppParams, LaunchAppResult } from "../types"; @@ -9,7 +10,12 @@ import type { LaunchAppAndroidServices, LaunchAppParams, LaunchAppResult } from // false-succeeded on `Status: null` when the activity failed in onCreate. export function assertAmStartOk(out: string): void { if (!/Status:\s*ok/i.test(out)) { - throw new Error(`am start failed: ${out.trim()}`); + throw new FailureError(`am start failed: ${out.trim()}`, { + error_code: FAILURE_CODES.ANDROID_LAUNCH_AM_START_FAILED, + failure_stage: "android_launch_am_start", + failure_area: "tool_server", + error_kind: "subprocess", + }); } // "Warning: Activity not started, its current task has been brought to the // front" also comes with Status: ok and means the app is foregrounded. @@ -30,10 +36,16 @@ export async function resolveLauncherActivity(udid: string, bundleId: string): P .filter(Boolean) .pop(); if (!last || !/^[\w.]+\/[\w.$]+$/.test(last)) { - throw new Error( + throw new FailureError( `Could not resolve a LAUNCHER activity for ${bundleId}. ` + `Install the app first, or pass an explicit \`activity\`. ` + - `(resolve-activity output: ${raw.trim() || "empty"})` + `(resolve-activity output: ${raw.trim() || "empty"})`, + { + error_code: FAILURE_CODES.ANDROID_LAUNCH_ACTIVITY_RESOLVE_FAILED, + failure_stage: "android_launch_resolve_activity", + failure_area: "tool_server", + error_kind: "subprocess", + } ); } return last; diff --git a/packages/tool-server/src/tools/launch-app/platforms/ios.ts b/packages/tool-server/src/tools/launch-app/platforms/ios.ts index ddc5814c..aff908df 100644 --- a/packages/tool-server/src/tools/launch-app/platforms/ios.ts +++ b/packages/tool-server/src/tools/launch-app/platforms/ios.ts @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { FAILURE_CODES, FailureError, subprocessFailureMetadata } from "@argent/registry"; import { precheckNativeDevtools } from "../../../blueprints/native-devtools"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; import type { LaunchAppIosServices, LaunchAppParams, LaunchAppResult } from "../types"; @@ -11,7 +12,21 @@ export const iosImpl: PlatformImpl { const blocked = await precheckNativeDevtools(services.nativeDevtools, params.udid); if (blocked) return blocked; - await execFileAsync("xcrun", ["simctl", "launch", params.udid, params.bundleId]); + try { + await execFileAsync("xcrun", ["simctl", "launch", params.udid, params.bundleId]); + } catch (err) { + throw new FailureError( + `Failed to launch iOS app ${params.bundleId} on ${params.udid}.`, + { + error_code: FAILURE_CODES.IOS_LAUNCH_SIMCTL_FAILED, + failure_stage: "ios_launch_app_simctl_launch", + failure_area: "tool_server", + error_kind: "subprocess", + ...subprocessFailureMetadata(err, "xcrun_simctl"), + }, + { cause: err instanceof Error ? err : new Error(String(err)) } + ); + } return { launched: true, bundleId: params.bundleId }; }, }; diff --git a/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts b/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts index d0fc98e6..1de05b73 100644 --- a/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts +++ b/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { nativeDevtoolsRef, precheckNativeDevtools, @@ -78,7 +78,12 @@ If status is restart_required: call restart-app then retry.`, )) as { screenFrame?: unknown; elements?: unknown[]; error?: string }; if (result.error) { - throw new Error(result.error); + throw new FailureError(result.error, { + error_code: FAILURE_CODES.NATIVE_DEVTOOLS_DESCRIBE_ERROR, + failure_stage: "native_devtools_describe_screen", + failure_area: "tool_server", + error_kind: "unknown", + }); } const parsed = parseNativeDescribeScreenResult(result); diff --git a/packages/tool-server/src/tools/native-devtools/native-find-views.ts b/packages/tool-server/src/tools/native-devtools/native-find-views.ts index fe5e7467..40a42b27 100644 --- a/packages/tool-server/src/tools/native-devtools/native-find-views.ts +++ b/packages/tool-server/src/tools/native-devtools/native-find-views.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { nativeDevtoolsRef, precheckNativeDevtools, @@ -77,7 +77,12 @@ Fails if native devtools are not connected, the app is not running, or status is )) as { matches?: unknown[]; error?: string }; if (result.error) { - throw new Error(result.error); + throw new FailureError(result.error, { + error_code: FAILURE_CODES.NATIVE_DEVTOOLS_FIND_VIEWS_ERROR, + failure_stage: "native_devtools_find_views", + failure_area: "tool_server", + error_kind: "unknown", + }); } return { status: "ok", matches: result.matches ?? [] }; diff --git a/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts b/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts index 8ea724ce..e77919e0 100644 --- a/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts +++ b/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { nativeDevtoolsRef, precheckNativeDevtools, @@ -102,7 +102,12 @@ If status is restart_required: call restart-app then retry.`, )) as { view?: unknown | null; error?: string }; if (result.error) { - throw new Error(result.error); + throw new FailureError(result.error, { + error_code: FAILURE_CODES.NATIVE_DEVTOOLS_USER_INTERACTABLE_VIEW_AT_POINT_ERROR, + failure_stage: "native_devtools_user_interactable_view_at_point", + failure_area: "tool_server", + error_kind: "unknown", + }); } return { status: "ok", view: result.view ?? null }; diff --git a/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts b/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts index 9242a023..bd012535 100644 --- a/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts +++ b/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { nativeDevtoolsRef, precheckNativeDevtools, @@ -102,7 +102,12 @@ If status is restart_required: call restart-app then retry.`, )) as { view?: unknown | null; error?: string }; if (result.error) { - throw new Error(result.error); + throw new FailureError(result.error, { + error_code: FAILURE_CODES.NATIVE_DEVTOOLS_VIEW_AT_POINT_ERROR, + failure_stage: "native_devtools_view_at_point", + failure_area: "tool_server", + error_kind: "unknown", + }); } return { status: "ok", view: result.view ?? null }; diff --git a/packages/tool-server/src/tools/open-url/platforms/android.ts b/packages/tool-server/src/tools/open-url/platforms/android.ts index dfac0755..634ea0fc 100644 --- a/packages/tool-server/src/tools/open-url/platforms/android.ts +++ b/packages/tool-server/src/tools/open-url/platforms/android.ts @@ -1,3 +1,4 @@ +import { FAILURE_CODES, FailureError } from "@argent/registry"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; import { adbShell } from "../../../utils/adb"; import type { OpenUrlParams, OpenUrlResult, OpenUrlServices } from "../types"; @@ -19,7 +20,12 @@ export const androidImpl: PlatformImpl = { requires: ["xcrun"], handler: async (_services, params) => { - await execFileAsync("xcrun", ["simctl", "openurl", params.udid, params.url]); + try { + await execFileAsync("xcrun", ["simctl", "openurl", params.udid, params.url]); + } catch (err) { + throw new FailureError( + `Failed to open URL on iOS simulator ${params.udid}.`, + { + error_code: FAILURE_CODES.IOS_OPEN_URL_FAILED, + failure_stage: "ios_open_url_simctl_openurl", + failure_area: "tool_server", + error_kind: "subprocess", + ...subprocessFailureMetadata(err, "xcrun_simctl"), + }, + { cause: err instanceof Error ? err : new Error(String(err)) } + ); + } return { opened: true, url: params.url }; }, }; diff --git a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts index ab3ff866..d5cccd15 100644 --- a/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts +++ b/packages/tool-server/src/tools/profiler/combined/profiler-combined-report.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { getCachedProfilerPaths } from "../../../blueprints/react-profiler-session"; import { nativeProfilerSessionRef, @@ -52,19 +52,34 @@ Fails if either react-profiler-analyze or native-profiler-analyze has not been c // Validate prerequisites if (!nativeApi.parsedData) { - throw new Error("No native profiler data. Run native-profiler-analyze first."); + throw new FailureError("No native profiler data. Run native-profiler-analyze first.", { + error_code: FAILURE_CODES.PROFILER_DATA_NOT_LOADED, + failure_stage: "profiler_combined_report_load_native_data", + failure_area: "tool_server", + error_kind: "validation", + }); } // Read-only: resolve react paths from cache only — no live CDP connection needed. const sessionPaths = getCachedProfilerPaths(params.port, params.device_id); if (!sessionPaths?.commitsPath) { - throw new Error("No React commit data. Run react-profiler-analyze first."); + throw new FailureError("No React commit data. Run react-profiler-analyze first.", { + error_code: FAILURE_CODES.PROFILER_DATA_NOT_LOADED, + failure_stage: "profiler_combined_report_load_react_data", + failure_area: "tool_server", + error_kind: "validation", + }); } const onDisk = await readCommitTree(sessionPaths.commitsPath); const commitTree = { commits: onDisk.commits, hookNames: new Map() }; if (commitTree.commits.length === 0) { - throw new Error("No React commit data. Run react-profiler-analyze first."); + throw new FailureError("No React commit data. Run react-profiler-analyze first.", { + error_code: FAILURE_CODES.PROFILER_DATA_NOT_LOADED, + failure_stage: "profiler_combined_report_load_react_data", + failure_area: "tool_server", + error_kind: "validation", + }); } let cpuProfile = null; @@ -76,19 +91,37 @@ Fails if either react-profiler-analyze or native-profiler-analyze has not been c const nativeWallStart = nativeApi.wallClockStartMs; if (!reactWallStart && !nativeWallStart) { - throw new Error( + throw new FailureError( "Missing wall-clock anchor from both profilers. Re-run the full profiling session " + - "(native-profiler-start + react-profiler-start)." + "(native-profiler-start + react-profiler-start).", + { + error_code: FAILURE_CODES.PROFILER_DATA_NOT_LOADED, + failure_stage: "profiler_combined_report_time_anchor", + failure_area: "tool_server", + error_kind: "validation", + } ); } else if (!reactWallStart) { - throw new Error( + throw new FailureError( "Missing wall-clock anchor from React Profiler (profileStartWallMs not found). " + - "Re-run the profiling session starting with react-profiler-start." + "Re-run the profiling session starting with react-profiler-start.", + { + error_code: FAILURE_CODES.PROFILER_DATA_NOT_LOADED, + failure_stage: "profiler_combined_report_time_anchor", + failure_area: "tool_server", + error_kind: "validation", + } ); } else if (!nativeWallStart) { - throw new Error( + throw new FailureError( "Missing wall-clock anchor from native profiler (wallClockStartMs not found). " + - "Re-run the profiling session starting with native-profiler-start." + "Re-run the profiling session starting with native-profiler-start.", + { + error_code: FAILURE_CODES.PROFILER_DATA_NOT_LOADED, + failure_stage: "profiler_combined_report_time_anchor", + failure_area: "tool_server", + error_kind: "validation", + } ); } diff --git a/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-analyze.ts b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-analyze.ts index d299a772..43eab78c 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-analyze.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-analyze.ts @@ -1,6 +1,6 @@ import { promises as fs } from "fs"; import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { nativeProfilerSessionRef, type NativeProfilerSessionApi, @@ -56,7 +56,12 @@ Fails if native-profiler-stop has not been called first to export trace data.`, const api = services.session as NativeProfilerSessionApi; if (!api.exportedFiles) { - throw new Error("No exported trace data found. Call native-profiler-stop first."); + throw new FailureError("No exported trace data found. Call native-profiler-stop first.", { + error_code: FAILURE_CODES.PROFILER_NATIVE_TRACE_MISSING, + failure_stage: "native_profiler_analyze_load_exports", + failure_area: "tool_server", + error_kind: "validation", + }); } // Pre-flight every set path: if the file is missing/unreadable the parsers diff --git a/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-start.ts b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-start.ts index 9e4d8d60..22ed2081 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-start.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-start.ts @@ -1,7 +1,12 @@ import { z } from "zod"; import { spawn, execFileSync, type ChildProcess } from "child_process"; import * as path from "path"; -import type { ToolDefinition } from "@argent/registry"; +import { + FAILURE_CODES, + FailureError, + subprocessFailureMetadata, + type ToolDefinition, +} from "@argent/registry"; import { nativeProfilerSessionRef, type NativeProfilerSessionApi, @@ -52,9 +57,17 @@ function detectRunningApp(udid: string): string { }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - throw new Error( + throw new FailureError( `Failed to enumerate running processes on simulator ${udid} within ${DETECT_RUNNING_APP_TIMEOUT_MS} ms. ` + - `Verify the simulator is booted and responsive, then retry. Underlying error: ${msg}` + `Verify the simulator is booted and responsive, then retry. Underlying error: ${msg}`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_APP_PROCESS_LIST_FAILED, + failure_stage: "native_profiler_detect_running_processes", + failure_area: "tool_server", + error_kind: "subprocess", + ...subprocessFailureMetadata(err, "xcrun_simctl"), + }, + { cause: err instanceof Error ? err : new Error(String(err)) } ); } @@ -67,8 +80,14 @@ function detectRunningApp(udid: string): string { } if (runningBundleIds.size === 0) { - throw new Error( - "No running apps detected on the simulator. Launch the app first using `launch-app`, then retry." + throw new FailureError( + "No running apps detected on the simulator. Launch the app first using `launch-app`, then retry.", + { + error_code: FAILURE_CODES.NATIVE_PROFILER_NO_RUNNING_APPS, + failure_stage: "native_profiler_detect_running_processes", + failure_area: "tool_server", + error_kind: "not_found", + } ); } @@ -88,9 +107,17 @@ function detectRunningApp(udid: string): string { }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - throw new Error( + throw new FailureError( `Failed to list installed apps on simulator ${udid} within ${DETECT_RUNNING_APP_TIMEOUT_MS} ms. ` + - `Verify the simulator is booted and responsive, then retry. Underlying error: ${msg}` + `Verify the simulator is booted and responsive, then retry. Underlying error: ${msg}`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_APP_LIST_FAILED, + failure_stage: "native_profiler_list_installed_apps", + failure_area: "tool_server", + error_kind: "subprocess", + ...subprocessFailureMetadata(err, "xcrun_simctl"), + }, + { cause: err instanceof Error ? err : new Error(String(err)) } ); } @@ -104,8 +131,14 @@ function detectRunningApp(udid: string): string { } if (runningUserApps.length === 0) { - throw new Error( - "No running user apps detected on the simulator (only system apps are running). Launch the app first using `launch-app`, then retry." + throw new FailureError( + "No running user apps detected on the simulator (only system apps are running). Launch the app first using `launch-app`, then retry.", + { + error_code: FAILURE_CODES.NATIVE_PROFILER_NO_RUNNING_USER_APPS, + failure_stage: "native_profiler_detect_running_user_app", + failure_area: "tool_server", + error_kind: "not_found", + } ); } @@ -116,8 +149,14 @@ function detectRunningApp(udid: string): string { ` - ${a.CFBundleExecutable} (${a.CFBundleIdentifier}${a.CFBundleDisplayName ? `, "${a.CFBundleDisplayName}"` : ""})` ) .join("\n"); - throw new Error( - `Multiple user apps are running on the simulator:\n${appList}\nSpecify \`app_process\` with the CFBundleExecutable of the app you want to profile.` + throw new FailureError( + `Multiple user apps are running on the simulator:\n${appList}\nSpecify \`app_process\` with the CFBundleExecutable of the app you want to profile.`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS, + failure_stage: "native_profiler_detect_running_user_app", + failure_area: "tool_server", + error_kind: "validation", + } ); } @@ -204,7 +243,15 @@ Fails if no app is running on the device, the platform is not supported yet, or const api = services.session as NativeProfilerSessionApi; if (api.profilingActive) { - throw new Error(`A native profiling session is already running (PID: ${api.xctracePid}).`); + throw new FailureError( + `A native profiling session is already running (PID: ${api.xctracePid}).`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_SESSION_ALREADY_RUNNING, + failure_stage: "native_profiler_start_session_state", + failure_area: "tool_server", + error_kind: "validation", + } + ); } const templatePath = params.template_path ?? DEFAULT_TEMPLATE_PATH; @@ -266,7 +313,13 @@ Fails if no app is running on the device, the platform is not supported yet, or // already dead } resetStartState(api); - throw new Error("xctrace process has no pid; cannot resolve start."); + throw new FailureError("xctrace process has no pid; cannot resolve start.", { + error_code: FAILURE_CODES.NATIVE_PROFILER_XCTRACE_NO_PID, + failure_stage: "native_profiler_xctrace_start", + failure_area: "tool_server", + error_kind: "subprocess", + failure_command: "xctrace", + }); } return { child: xctraceProcess, pid: xctraceProcess.pid }; @@ -293,11 +346,17 @@ Fails if no app is running on the device, the platform is not supported yet, or } } const totalMs = Date.now() - startMs; - throw new Error( + throw new FailureError( `xctrace could not find process "${appProcess}" after ${MAX_START_ATTEMPTS} attempts within ${totalMs} ms. ` + `The app appears to be cold-launching — its bundle is registered with launchd, but xctrace's process resolver hasn't seen it yet. ` + `Wait 1–2 seconds for the app to finish launching and retry. ` + - `If the wrong app is being detected, pass app_process explicitly with the CFBundleExecutable.` + `If the wrong app is being detected, pass app_process explicitly with the CFBundleExecutable.`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_XCTRACE_PROCESS_NOT_FOUND, + failure_stage: "native_profiler_xctrace_start", + failure_area: "tool_server", + error_kind: "subprocess", + } ); }; diff --git a/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-stop.ts b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-stop.ts index d15d54dc..3327b06b 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-stop.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-stop.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { nativeProfilerSessionRef, type NativeProfilerSessionApi, @@ -67,8 +67,14 @@ Fails if no active native-profiler-start session exists for the given device_id. } if (!api.profilingActive || !api.xctraceProcess || !api.traceFile) { - throw new Error( - "No active native profiling session found. Call native-profiler-start first." + throw new FailureError( + "No active native profiling session found. Call native-profiler-start first.", + { + error_code: FAILURE_CODES.NATIVE_PROFILER_NO_ACTIVE_SESSION, + failure_stage: "native_profiler_stop_session_state", + failure_area: "tool_server", + error_kind: "validation", + } ); } diff --git a/packages/tool-server/src/tools/profiler/query/profiler-commit-query.ts b/packages/tool-server/src/tools/profiler/query/profiler-commit-query.ts index d89dc641..cd20602f 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-commit-query.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-commit-query.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { getCachedProfilerPaths } from "../../../blueprints/react-profiler-session"; import type { DevToolsFiberCommit, @@ -44,14 +44,26 @@ const zodSchema = z.object({ async function getCommitTree(port: number, deviceId: string): Promise { const sessionPaths = getCachedProfilerPaths(port, deviceId); if (!sessionPaths?.commitsPath) { - throw new Error( - "No commit data stored. Run react-profiler-start → exercise app → react-profiler-stop first." + throw new FailureError( + "No commit data stored. Run react-profiler-start → exercise app → react-profiler-stop first.", + { + error_code: FAILURE_CODES.PROFILER_DATA_NOT_LOADED, + failure_stage: "profiler_commit_query_load_data", + failure_area: "tool_server", + error_kind: "validation", + } ); } const onDisk = await readCommitTree(sessionPaths.commitsPath); if (onDisk.commits.length === 0) { - throw new Error( - "No commit data stored. Run react-profiler-start → exercise app → react-profiler-stop first." + throw new FailureError( + "No commit data stored. Run react-profiler-start → exercise app → react-profiler-stop first.", + { + error_code: FAILURE_CODES.PROFILER_DATA_NOT_LOADED, + failure_stage: "profiler_commit_query_load_data", + failure_area: "tool_server", + error_kind: "validation", + } ); } return { commits: onDisk.commits, hookNames: new Map() }; @@ -334,14 +346,24 @@ Fails if react-profiler-stop has not been called or no commit data is stored.`, switch (params.mode) { case "by_component": { if (!params.component_name) { - throw new Error("by_component mode requires the component_name parameter."); + throw new FailureError("by_component mode requires the component_name parameter.", { + error_code: FAILURE_CODES.PROFILER_QUERY_REQUIRED_PARAM_MISSING, + failure_stage: "profiler_commit_query_params", + failure_area: "tool_server", + error_kind: "validation", + }); } return renderByComponent(commitTree.commits, params.component_name, params.top_n); } case "by_time_range": { if (!params.time_range_ms) { - throw new Error("by_time_range mode requires the time_range_ms parameter."); + throw new FailureError("by_time_range mode requires the time_range_ms parameter.", { + error_code: FAILURE_CODES.PROFILER_QUERY_REQUIRED_PARAM_MISSING, + failure_stage: "profiler_commit_query_params", + failure_area: "tool_server", + error_kind: "validation", + }); } return renderByTimeRange( commitTree.commits, @@ -353,20 +375,35 @@ Fails if react-profiler-stop has not been called or no commit data is stored.`, case "by_index": { if (params.commit_index == null) { - throw new Error("by_index mode requires the commit_index parameter."); + throw new FailureError("by_index mode requires the commit_index parameter.", { + error_code: FAILURE_CODES.PROFILER_QUERY_REQUIRED_PARAM_MISSING, + failure_stage: "profiler_commit_query_params", + failure_area: "tool_server", + error_kind: "validation", + }); } return renderByIndex(commitTree.commits, params.commit_index); } case "cascade_tree": { if (params.commit_index == null) { - throw new Error("cascade_tree mode requires the commit_index parameter."); + throw new FailureError("cascade_tree mode requires the commit_index parameter.", { + error_code: FAILURE_CODES.PROFILER_QUERY_REQUIRED_PARAM_MISSING, + failure_stage: "profiler_commit_query_params", + failure_area: "tool_server", + error_kind: "validation", + }); } return renderCascadeTree(commitTree.commits, params.commit_index); } default: - throw new Error(`Unknown mode: ${params.mode}`); + throw new FailureError(`Unknown mode: ${params.mode}`, { + error_code: FAILURE_CODES.PROFILER_QUERY_MODE_INVALID, + failure_stage: "profiler_commit_query_mode", + failure_area: "tool_server", + error_kind: "validation", + }); } }, }; diff --git a/packages/tool-server/src/tools/profiler/query/profiler-cpu-query.ts b/packages/tool-server/src/tools/profiler/query/profiler-cpu-query.ts index ee1bde9b..f1a031f0 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-cpu-query.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-cpu-query.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { type ProfilerSessionPaths, getCachedProfilerPaths, @@ -62,8 +62,14 @@ async function getIndex(sessionPaths: ProfilerSessionPaths): Promise<{ } | null; }> { if (!sessionPaths?.cpuProfilePath) { - throw new Error( - "No CPU profile stored. Run react-profiler-start → exercise the app → react-profiler-stop → react-profiler-analyze first." + throw new FailureError( + "No CPU profile stored. Run react-profiler-start → exercise the app → react-profiler-stop → react-profiler-analyze first.", + { + error_code: FAILURE_CODES.PROFILER_DATA_NOT_LOADED, + failure_stage: "profiler_cpu_query_load_data", + failure_area: "tool_server", + error_kind: "validation", + } ); } @@ -352,8 +358,14 @@ Fails if no CPU profile is stored — run react-profiler-stop first.`, async execute(_services, params) { const sessionPaths = getCachedProfilerPaths(params.port, params.device_id); if (!sessionPaths) { - throw new Error( - "No profiling data stored. Run react-profiler-start → exercise the app → react-profiler-stop → react-profiler-analyze first." + throw new FailureError( + "No profiling data stored. Run react-profiler-start → exercise the app → react-profiler-stop → react-profiler-analyze first.", + { + error_code: FAILURE_CODES.PROFILER_DATA_NOT_LOADED, + failure_stage: "profiler_cpu_query_load_session", + failure_area: "tool_server", + error_kind: "validation", + } ); } const { index, commitTree } = await getIndex(sessionPaths); @@ -369,7 +381,12 @@ Fails if no CPU profile is stored — run react-profiler-stop first.`, case "time_window": { if (!params.time_window_ms) { - throw new Error("time_window mode requires the time_window_ms parameter."); + throw new FailureError("time_window mode requires the time_window_ms parameter.", { + error_code: FAILURE_CODES.PROFILER_QUERY_REQUIRED_PARAM_MISSING, + failure_stage: "profiler_cpu_query_params", + failure_area: "tool_server", + error_kind: "validation", + }); } return renderTopFunctions( index, @@ -381,20 +398,35 @@ Fails if no CPU profile is stored — run react-profiler-stop first.`, case "call_tree": { if (!params.function_name) { - throw new Error("call_tree mode requires the function_name parameter."); + throw new FailureError("call_tree mode requires the function_name parameter.", { + error_code: FAILURE_CODES.PROFILER_QUERY_REQUIRED_PARAM_MISSING, + failure_stage: "profiler_cpu_query_params", + failure_area: "tool_server", + error_kind: "validation", + }); } return renderCallTree(index, params.function_name, params.top_n, params.include_callers); } case "component_cpu": { if (!params.component_name) { - throw new Error("component_cpu mode requires the component_name parameter."); + throw new FailureError("component_cpu mode requires the component_name parameter.", { + error_code: FAILURE_CODES.PROFILER_QUERY_REQUIRED_PARAM_MISSING, + failure_stage: "profiler_cpu_query_params", + failure_area: "tool_server", + error_kind: "validation", + }); } return renderComponentCpu(index, commitTree, params.component_name, params.top_n); } default: - throw new Error(`Unknown mode: ${params.mode}`); + throw new FailureError(`Unknown mode: ${params.mode}`, { + error_code: FAILURE_CODES.PROFILER_QUERY_MODE_INVALID, + failure_stage: "profiler_cpu_query_mode", + failure_area: "tool_server", + error_kind: "validation", + }); } }, }; diff --git a/packages/tool-server/src/tools/profiler/query/profiler-load.ts b/packages/tool-server/src/tools/profiler/query/profiler-load.ts index 400aed02..9d288f16 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-load.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-load.ts @@ -1,7 +1,12 @@ import { z } from "zod"; import { promises as fs } from "fs"; import * as path from "path"; -import type { ServiceRef, ToolDefinition } from "@argent/registry"; +import { + FAILURE_CODES, + FailureError, + type ServiceRef, + type ToolDefinition, +} from "@argent/registry"; import { cacheProfilerPaths, type ProfilerSessionPaths, @@ -180,9 +185,15 @@ async function loadReactSession( } if (!hasCpu && !hasCommits) { - throw new Error( + throw new FailureError( `No data found for React session "${sessionId}". ` + - `Expected files at:\n ${cpuPath}\n ${commitsPath}` + `Expected files at:\n ${cpuPath}\n ${commitsPath}`, + { + error_code: FAILURE_CODES.PROFILER_DATA_NOT_LOADED, + failure_stage: "profiler_load_react_session", + failure_area: "tool_server", + error_kind: "validation", + } ); } @@ -296,9 +307,15 @@ async function loadNativeSession( } if (!files.cpu && !files.hangs && !files.leaks) { - throw new Error( + throw new FailureError( `No native profiler XML files found for session "${sessionId}". ` + - `Expected files matching native-profiler-${sessionId}_raw_*.xml in ${debugDir}` + `Expected files matching native-profiler-${sessionId}_raw_*.xml in ${debugDir}`, + { + error_code: FAILURE_CODES.PROFILER_NATIVE_TRACE_MISSING, + failure_stage: "profiler_load_native_session", + failure_area: "tool_server", + error_kind: "validation", + } ); } @@ -349,8 +366,14 @@ Fails if the session_id is not found or required XML files are missing from disk case "load_react": { if (!params.session_id) { - throw new Error( - "load_react mode requires the session_id parameter. Use list mode first." + throw new FailureError( + "load_react mode requires the session_id parameter. Use list mode first.", + { + error_code: FAILURE_CODES.PROFILER_QUERY_REQUIRED_PARAM_MISSING, + failure_stage: "profiler_load_params", + failure_area: "tool_server", + error_kind: "validation", + } ); } return loadReactSession(debugDir, params.session_id, params.port, params.device_id); @@ -358,8 +381,14 @@ Fails if the session_id is not found or required XML files are missing from disk case "load_native": { if (!params.session_id) { - throw new Error( - "load_native mode requires the session_id parameter. Use list mode first." + throw new FailureError( + "load_native mode requires the session_id parameter. Use list mode first.", + { + error_code: FAILURE_CODES.PROFILER_QUERY_REQUIRED_PARAM_MISSING, + failure_stage: "profiler_load_params", + failure_area: "tool_server", + error_kind: "validation", + } ); } const api = services.session as NativeProfilerSessionApi; @@ -367,7 +396,12 @@ Fails if the session_id is not found or required XML files are missing from disk } default: - throw new Error(`Unknown mode: ${params.mode}`); + throw new FailureError(`Unknown mode: ${params.mode}`, { + error_code: FAILURE_CODES.PROFILER_QUERY_MODE_INVALID, + failure_stage: "profiler_load_mode", + failure_area: "tool_server", + error_kind: "validation", + }); } }, }; diff --git a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts index 48113192..915526c2 100644 --- a/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts +++ b/packages/tool-server/src/tools/profiler/query/profiler-stack-query.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { nativeProfilerSessionRef, type NativeProfilerSessionApi, @@ -37,8 +37,14 @@ const zodSchema = z.object({ function getParsedData(api: NativeProfilerSessionApi) { if (!api.parsedData) { - throw new Error( - "No parsed trace data. Run native-profiler-stop → native-profiler-analyze first." + throw new FailureError( + "No parsed trace data. Run native-profiler-stop → native-profiler-analyze first.", + { + error_code: FAILURE_CODES.PROFILER_DATA_NOT_LOADED, + failure_stage: "profiler_stack_query_load_data", + failure_area: "tool_server", + error_kind: "validation", + } ); } return api.parsedData; @@ -336,14 +342,24 @@ Fails if native-profiler-analyze has not been run or no parsed trace data is in switch (params.mode) { case "hang_stacks": { if (params.hang_index == null) { - throw new Error("hang_stacks mode requires the hang_index parameter."); + throw new FailureError("hang_stacks mode requires the hang_index parameter.", { + error_code: FAILURE_CODES.PROFILER_QUERY_REQUIRED_PARAM_MISSING, + failure_stage: "profiler_stack_query_params", + failure_area: "tool_server", + error_kind: "validation", + }); } return renderHangStacks(data.cpuSamples, data.uiHangs, params.hang_index, params.top_n); } case "function_callers": { if (!params.function_name) { - throw new Error("function_callers mode requires the function_name parameter."); + throw new FailureError("function_callers mode requires the function_name parameter.", { + error_code: FAILURE_CODES.PROFILER_QUERY_REQUIRED_PARAM_MISSING, + failure_stage: "profiler_stack_query_params", + failure_area: "tool_server", + error_kind: "validation", + }); } return renderFunctionCallers(data.cpuSamples, params.function_name, params.top_n); } @@ -360,7 +376,12 @@ Fails if native-profiler-analyze has not been run or no parsed trace data is in return renderLeakStacks(data.memoryLeaks, params.object_type, params.top_n); default: - throw new Error(`Unknown mode: ${params.mode}`); + throw new FailureError(`Unknown mode: ${params.mode}`, { + error_code: FAILURE_CODES.PROFILER_QUERY_MODE_INVALID, + failure_stage: "profiler_stack_query_mode", + failure_area: "tool_server", + error_kind: "validation", + }); } }, }; diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-analyze.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-analyze.ts index 12c09a20..cf037f8f 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-analyze.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-analyze.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { promises as fsPromises } from "fs"; -import type { ToolDefinition } from "@argent/registry"; +import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { type ProfilerSessionPaths, getCachedProfilerPaths, @@ -78,8 +78,14 @@ Fails if react-profiler-stop has not been called or no profiling data is stored. ); if (!sessionPaths) { - throw new Error( - "No profiling data stored. Call react-profiler-start → exercise the app → react-profiler-stop first." + throw new FailureError( + "No profiling data stored. Call react-profiler-start → exercise the app → react-profiler-stop first.", + { + error_code: FAILURE_CODES.REACT_PROFILER_ANALYZE_NO_DATA, + failure_stage: "react_profiler_analyze_load_data", + failure_area: "tool_server", + error_kind: "validation", + } ); } diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-cpu-summary.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-cpu-summary.ts index fd69e261..9482938b 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-cpu-summary.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-cpu-summary.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { getCachedProfilerPaths } from "../../../blueprints/react-profiler-session"; import type { HermesProfileNode, @@ -128,8 +128,14 @@ Fails if react-profiler-stop has not been called or no CPU profile is stored.`, async execute(_services, params) { const sessionPaths = getCachedProfilerPaths(params.port, params.device_id); if (!sessionPaths?.cpuProfilePath) { - throw new Error( - "No CPU profile stored. Call react-profiler-start, exercise the app, then react-profiler-stop." + throw new FailureError( + "No CPU profile stored. Call react-profiler-start, exercise the app, then react-profiler-stop.", + { + error_code: FAILURE_CODES.REACT_PROFILER_NO_CPU_PROFILE, + failure_stage: "react_profiler_cpu_summary_load_data", + failure_area: "tool_server", + error_kind: "validation", + } ); } diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-fiber-tree.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-fiber-tree.ts index af520d74..025c555b 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-fiber-tree.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-fiber-tree.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { REACT_PROFILER_SESSION_NAMESPACE, type ReactProfilerSessionApi, @@ -141,11 +141,21 @@ Fails if the React DevTools hook is not present or no fiber roots have been comm let result = await evalFiberTree(); if (result?.exceptionDetails) { - throw new Error(`Runtime exception: ${result.exceptionDetails.text ?? "unknown"}`); + throw new FailureError(`Runtime exception: ${result.exceptionDetails.text ?? "unknown"}`, { + error_code: FAILURE_CODES.REACT_PROFILER_RUNTIME_EXCEPTION, + failure_stage: "react_profiler_fiber_tree_runtime_eval", + failure_area: "tool_server", + error_kind: "subprocess", + }); } if (!result?.result?.value) { - throw new Error("No data returned from runtime evaluation."); + throw new FailureError("No data returned from runtime evaluation.", { + error_code: FAILURE_CODES.REACT_PROFILER_NO_RUNTIME_DATA, + failure_stage: "react_profiler_fiber_tree_runtime_eval", + failure_area: "tool_server", + error_kind: "subprocess", + }); } let parsed = JSON.parse(result.result.value) as unknown; @@ -166,10 +176,18 @@ Fails if the React DevTools hook is not present or no fiber roots have been comm if (typeof parsed === "object" && parsed !== null && "error" in parsed) { const errorMsg = (parsed as { error: string }).error; - throw new Error( + throw new FailureError( HOOK_NOT_PRESENT_ERRORS.has(errorMsg) ? HOOK_MISSING_MESSAGE - : `Fiber tree error: ${errorMsg}` + : `Fiber tree error: ${errorMsg}`, + { + error_code: HOOK_NOT_PRESENT_ERRORS.has(errorMsg) + ? FAILURE_CODES.REACT_PROFILER_DEVTOOLS_HOOK_MISSING + : FAILURE_CODES.REACT_PROFILER_HOOK_ERROR, + failure_stage: "react_profiler_fiber_tree_hook_read", + failure_area: "tool_server", + error_kind: "validation", + } ); } diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-renders.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-renders.ts index 49d99b04..c0b6a4ab 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-renders.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-renders.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolDefinition } from "@argent/registry"; +import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { REACT_PROFILER_SESSION_NAMESPACE, type ReactProfilerSessionApi, @@ -138,11 +138,21 @@ Fails if the React DevTools hook is not present in the runtime or the app is not let result = await evalRenders(); if (result?.exceptionDetails) { - throw new Error(`Runtime exception: ${result.exceptionDetails.text ?? "unknown"}`); + throw new FailureError(`Runtime exception: ${result.exceptionDetails.text ?? "unknown"}`, { + error_code: FAILURE_CODES.REACT_PROFILER_RUNTIME_EXCEPTION, + failure_stage: "react_profiler_renders_runtime_eval", + failure_area: "tool_server", + error_kind: "subprocess", + }); } if (!result?.result?.value) { - throw new Error("No data returned from runtime evaluation."); + throw new FailureError("No data returned from runtime evaluation.", { + error_code: FAILURE_CODES.REACT_PROFILER_NO_RUNTIME_DATA, + failure_stage: "react_profiler_renders_runtime_eval", + failure_area: "tool_server", + error_kind: "subprocess", + }); } let parsed = JSON.parse(result.result.value) as ParsedRenders; @@ -166,10 +176,18 @@ Fails if the React DevTools hook is not present in the runtime or the app is not const errorStr = getErrorString(parsed); if (errorStr !== null) { - throw new Error( + throw new FailureError( HOOK_NOT_PRESENT_ERRORS.has(errorStr) ? HOOK_MISSING_MESSAGE - : `React hook error: ${errorStr}` + : `React hook error: ${errorStr}`, + { + error_code: HOOK_NOT_PRESENT_ERRORS.has(errorStr) + ? FAILURE_CODES.REACT_PROFILER_DEVTOOLS_HOOK_MISSING + : FAILURE_CODES.REACT_PROFILER_HOOK_ERROR, + failure_stage: "react_profiler_renders_hook_read", + failure_area: "tool_server", + error_kind: "validation", + } ); } diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-start.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-start.ts index 1fca54a6..c4d0b6f0 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-start.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-start.ts @@ -1,6 +1,12 @@ import * as crypto from "node:crypto"; import { z } from "zod"; -import { type Registry, type ToolDefinition, ServiceState } from "@argent/registry"; +import { + FAILURE_CODES, + FailureError, + type Registry, + type ToolDefinition, + ServiceState, +} from "@argent/registry"; import { REACT_PROFILER_SESSION_NAMESPACE, type ReactProfilerSessionApi, @@ -122,8 +128,14 @@ Fails if the Hermes runtime is not reachable or the Metro CDP connection cannot await disposeAndWait(); api = await registry.resolveService(psUrn); if (!api.cdp.isConnected()) { - throw new Error( - "CDP connection not available. The Hermes runtime may still be loading. Call react-profiler-start again." + throw new FailureError( + "CDP connection not available. The Hermes runtime may still be loading. Call react-profiler-start again.", + { + error_code: FAILURE_CODES.REACT_PROFILER_CDP_NOT_CONNECTED, + failure_stage: "react_profiler_start_cdp_connection", + failure_area: "tool_server", + error_kind: "network", + } ); } } @@ -136,13 +148,27 @@ Fails if the Hermes runtime is not reachable or the Metro CDP connection cannot // Snapshot backend state so we can decide whether to start, take over, or refuse. let stateJson = (await cdp.evaluate(READ_STATE_SCRIPT)) as string | undefined; if (!stateJson) { - throw new Error("Failed to read React profiler state from runtime (no value returned)."); + throw new FailureError( + "Failed to read React profiler state from runtime (no value returned).", + { + error_code: FAILURE_CODES.REACT_PROFILER_STATE_READ_FAILED, + failure_stage: "react_profiler_start_read_state", + failure_area: "tool_server", + error_kind: "subprocess", + } + ); } let state = JSON.parse(stateJson) as ReadStateResult; if (!state.hookExists) { - throw new Error( - "React DevTools is not available in this app. This usually means the app is a production build. Ask the user to run a development build of the app, then retry." + throw new FailureError( + "React DevTools is not available in this app. This usually means the app is a production build. Ask the user to run a development build of the app, then retry.", + { + error_code: FAILURE_CODES.REACT_PROFILER_DEVTOOLS_HOOK_MISSING, + failure_stage: "react_profiler_start_devtools_hook", + failure_area: "tool_server", + error_kind: "validation", + } ); } @@ -158,14 +184,25 @@ Fails if the Hermes runtime is not reachable or the Metro CDP connection cannot | string | undefined; if (!bootstrapJson) { - throw new Error( - "Failed to attach React DevTools backend (no value returned from runtime)." + throw new FailureError( + "Failed to attach React DevTools backend (no value returned from runtime).", + { + error_code: FAILURE_CODES.REACT_PROFILER_DEVTOOLS_BACKEND_ATTACH_FAILED, + failure_stage: "react_profiler_start_bootstrap_backend", + failure_area: "tool_server", + error_kind: "subprocess", + } ); } const bootstrap = JSON.parse(bootstrapJson) as BootstrapResult; if (!bootstrap.ok) { - throw new Error(bootstrapFailureMessage(bootstrap)); + throw new FailureError(bootstrapFailureMessage(bootstrap), { + error_code: FAILURE_CODES.REACT_PROFILER_DEVTOOLS_BACKEND_BOOTSTRAP_FAILED, + failure_stage: "react_profiler_start_bootstrap_backend", + failure_area: "tool_server", + error_kind: "subprocess", + }); } // Re-run the setup script: it walks `hook.rendererInterfaces` and @@ -177,8 +214,14 @@ Fails if the Hermes runtime is not reachable or the Metro CDP connection cannot stateJson = (await cdp.evaluate(READ_STATE_SCRIPT)) as string | undefined; if (!stateJson) { - throw new Error( - "Failed to re-read React profiler state after attach (no value returned)." + throw new FailureError( + "Failed to re-read React profiler state after attach (no value returned).", + { + error_code: FAILURE_CODES.REACT_PROFILER_STATE_READ_FAILED, + failure_stage: "react_profiler_start_reread_state", + failure_area: "tool_server", + error_kind: "subprocess", + } ); } state = JSON.parse(stateJson) as ReadStateResult; @@ -188,8 +231,14 @@ Fails if the Hermes runtime is not reachable or the Metro CDP connection cannot !("rendererInterfaceFound" in state) || !state.rendererInterfaceFound ) { - throw new Error( - "Attached the React DevTools backend but no React renderer registered itself afterwards. Ask the user to fully reload the JS bundle and retry." + throw new FailureError( + "Attached the React DevTools backend but no React renderer registered itself afterwards. Ask the user to fully reload the JS bundle and retry.", + { + error_code: FAILURE_CODES.REACT_PROFILER_DEVTOOLS_RENDERER_MISSING, + failure_stage: "react_profiler_start_renderer_check", + failure_area: "tool_server", + error_kind: "validation", + } ); } } @@ -241,7 +290,12 @@ Fails if the Hermes runtime is not reachable or the Metro CDP connection cannot | string | undefined; if (!startJson) { - throw new Error("Failed to start React profiler (no value returned from runtime)."); + throw new FailureError("Failed to start React profiler (no value returned from runtime).", { + error_code: FAILURE_CODES.REACT_PROFILER_NO_RUNTIME_DATA, + failure_stage: "react_profiler_start_runtime_start", + failure_area: "tool_server", + error_kind: "subprocess", + }); } const startResult = JSON.parse(startJson) as { ok: boolean; @@ -255,17 +309,29 @@ Fails if the Hermes runtime is not reachable or the Metro CDP connection cannot if (!startResult.ok) { // Roll back CPU sampler so we don't leak state. await cdp.send("Profiler.stop").catch(ignore); - throw new Error( + throw new FailureError( `React profiler failed to start (${startResult.reason ?? "unknown"}${ startResult.message ? `: ${startResult.message}` : "" - })` + })`, + { + error_code: FAILURE_CODES.REACT_PROFILER_START_FAILED, + failure_stage: "react_profiler_start_runtime_start", + failure_area: "tool_server", + error_kind: "subprocess", + } ); } if (startResult.isProfilingFlagSet !== true || startResult.ownerInstalled !== true) { await cdp.send("Profiler.stop").catch(ignore); - throw new Error( - `React profiler failed to start (post-start verification failed: isProfilingFlagSet=${startResult.isProfilingFlagSet === true}, ownerInstalled=${startResult.ownerInstalled === true})` + throw new FailureError( + `React profiler failed to start (post-start verification failed: isProfilingFlagSet=${startResult.isProfilingFlagSet === true}, ownerInstalled=${startResult.ownerInstalled === true})`, + { + error_code: FAILURE_CODES.REACT_PROFILER_START_VERIFY_FAILED, + failure_stage: "react_profiler_start_verify", + failure_area: "tool_server", + error_kind: "subprocess", + } ); } diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts index d41f3f7f..e7ed8aa3 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { ServiceState } from "@argent/registry"; +import { FAILURE_CODES, FailureError, ServiceState } from "@argent/registry"; import type { Registry, ToolDefinition } from "@argent/registry"; import { REACT_PROFILER_SESSION_NAMESPACE, @@ -169,9 +169,15 @@ Fails if no active profiling session exists or the CDP connection was lost durin const entry = snapshot.services.get(psUrn); if (!entry || entry.state !== ServiceState.RUNNING) { - throw new Error( + throw new FailureError( "No active profiling session. The session may have been lost due to a Metro reload. " + - "Call react-profiler-start to begin a new session." + "Call react-profiler-start to begin a new session.", + { + error_code: FAILURE_CODES.REACT_PROFILER_NO_ACTIVE_SESSION, + failure_stage: "react_profiler_stop_session_lookup", + failure_area: "tool_server", + error_kind: "not_found", + } ); } @@ -180,9 +186,15 @@ Fails if no active profiling session exists or the CDP connection was lost durin if (!api.cdp.isConnected()) { api.profilingActive = false; - throw new Error( + throw new FailureError( "CDP connection lost — profiling data could not be collected. " + - "Call react-profiler-start to begin a new session." + "Call react-profiler-start to begin a new session.", + { + error_code: FAILURE_CODES.REACT_PROFILER_CDP_CONNECTION_LOST, + failure_stage: "react_profiler_stop_cdp_connection", + failure_area: "tool_server", + error_kind: "network", + } ); } @@ -192,7 +204,12 @@ Fails if no active profiling session exists or the CDP connection was lost durin profile?: HermesCpuProfile; }; if (!cpuResult?.profile) { - throw new Error("Profiler returned no profile data."); + throw new FailureError("Profiler returned no profile data.", { + error_code: FAILURE_CODES.REACT_PROFILER_NO_CPU_PROFILE, + failure_stage: "react_profiler_stop_cpu_profile", + failure_area: "tool_server", + error_kind: "unknown", + }); } const profile = cpuResult.profile; @@ -204,13 +221,24 @@ Fails if no active profiling session exists or the CDP connection was lost durin timeout: 60000, })) as { result?: { value?: string }; exceptionDetails?: { text?: string } }; if (stopReadStr.exceptionDetails) { - throw new Error( - `Runtime exception while reading profiling data: ${stopReadStr.exceptionDetails.text ?? "unknown"}` + throw new FailureError( + `Runtime exception while reading profiling data: ${stopReadStr.exceptionDetails.text ?? "unknown"}`, + { + error_code: FAILURE_CODES.REACT_PROFILER_RUNTIME_EXCEPTION, + failure_stage: "react_profiler_stop_runtime_read", + failure_area: "tool_server", + error_kind: "unknown", + } ); } const stopReadRaw = stopReadStr.result?.value; if (!stopReadRaw) { - throw new Error("No profiling data returned from runtime."); + throw new FailureError("No profiling data returned from runtime.", { + error_code: FAILURE_CODES.REACT_PROFILER_NO_RUNTIME_DATA, + failure_stage: "react_profiler_stop_runtime_read", + failure_area: "tool_server", + error_kind: "unknown", + }); } const stopRead = JSON.parse(stopReadRaw) as StopReadResult; diff --git a/packages/tool-server/src/tools/reinstall-app/platforms/android.ts b/packages/tool-server/src/tools/reinstall-app/platforms/android.ts index 8edd3ac4..d22cee76 100644 --- a/packages/tool-server/src/tools/reinstall-app/platforms/android.ts +++ b/packages/tool-server/src/tools/reinstall-app/platforms/android.ts @@ -1,4 +1,5 @@ import { resolve as resolvePath } from "node:path"; +import { FAILURE_CODES, FailureError } from "@argent/registry"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; import { runAdb } from "../../../utils/adb"; import type { ReinstallAppParams, ReinstallAppResult, ReinstallAppServices } from "../types"; @@ -29,7 +30,12 @@ export const androidImpl: PlatformImpl< const { stdout, stderr } = await runAdb(args, { timeoutMs: 180_000 }); const output = `${stdout}\n${stderr}`; if (!/Success/i.test(output)) { - throw new Error(`adb install failed: ${output.trim()}`); + throw new FailureError(`adb install failed: ${output.trim()}`, { + error_code: FAILURE_CODES.ANDROID_REINSTALL_INSTALL_FAILED, + failure_stage: "android_reinstall_adb_install", + failure_area: "tool_server", + error_kind: "subprocess", + }); } return { reinstalled: true, bundleId }; }, diff --git a/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts b/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts index 9cd143ee..4d81f91b 100644 --- a/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts +++ b/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts @@ -1,6 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { resolve as resolvePath } from "node:path"; +import { FAILURE_CODES, FailureError, subprocessFailureMetadata } from "@argent/registry"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; import type { ReinstallAppParams, ReinstallAppResult, ReinstallAppServices } from "../types"; @@ -16,7 +17,21 @@ export const iosImpl: PlatformImpl { const path = await resolveAndroidBinary("adb"); if (!path) { - throw new Error( + throw new FailureError( "`adb` not found on PATH or under `$ANDROID_HOME/platform-tools`. " + - "Install Android SDK Platform Tools or set `$ANDROID_HOME` to your SDK root." + "Install Android SDK Platform Tools or set `$ANDROID_HOME` to your SDK root.", + { + error_code: FAILURE_CODES.ANDROID_ADB_NOT_FOUND, + failure_stage: "android_adb_resolve_binary", + failure_area: "tool_server", + error_kind: "dependency_missing", + } ); } return path; @@ -27,9 +39,15 @@ async function resolveAdbOrThrow(): Promise { export async function resolveEmulatorOrThrow(): Promise { const path = await resolveAndroidBinary("emulator"); if (!path) { - throw new Error( + throw new FailureError( "`emulator` not found on PATH or under `$ANDROID_HOME/emulator`. " + - "Install the Android Emulator package or set `$ANDROID_HOME` to your SDK root." + "Install the Android Emulator package or set `$ANDROID_HOME` to your SDK root.", + { + error_code: FAILURE_CODES.ANDROID_EMULATOR_NOT_FOUND, + failure_stage: "android_emulator_resolve_binary", + failure_area: "tool_server", + error_kind: "dependency_missing", + } ); } return path; @@ -112,15 +130,22 @@ function describeAdbFailure(args: string[], err: unknown): Error { message?: string; }; const argv = args.join(" "); + const signal: FailureSignal = { + error_code: FAILURE_CODES.ANDROID_ADB_COMMAND_FAILED, + failure_stage: "android_adb_command", + failure_area: "tool_server", + error_kind: e.killed || e.signal ? "timeout" : "subprocess", + ...subprocessFailureMetadata(err, "adb"), + }; const ioDetail = (e.stderr ?? "").trim() || (e.stdout ?? "").trim(); - if (ioDetail) return new Error(`adb ${argv} failed: ${ioDetail}`); + if (ioDetail) return new FailureError(`adb ${argv} failed: ${ioDetail}`, signal); const meta: string[] = []; if (e.killed) meta.push("killed=true"); if (e.signal) meta.push(`signal=${e.signal}`); if (e.code) meta.push(`code=${e.code}`); const baseMsg = (e.message ?? String(err)).trim(); const suffix = meta.length ? ` (${meta.join(" ")})` : ""; - return new Error(`adb ${argv} failed: ${baseMsg}${suffix}`); + return new FailureError(`adb ${argv} failed: ${baseMsg}${suffix}`, signal); } /** @@ -379,16 +404,28 @@ export async function waitForBootCompleted( } catch (err) { const message = err instanceof Error ? err.message : String(err); if (isTerminalAdbError(message)) { - throw new Error( + throw new FailureError( `Cannot wait for ${serial} to boot — adb reports the device is in a terminal state: ${message}.` + - ` Authorise the device, reconnect it, or pick a different target.` + ` Authorise the device, reconnect it, or pick a different target.`, + { + error_code: FAILURE_CODES.ANDROID_ADB_BOOT_TERMINAL_STATE, + failure_stage: "android_wait_for_boot", + failure_area: "tool_server", + error_kind: "subprocess", + }, + { cause: err instanceof Error ? err : new Error(String(err)) } ); } // Otherwise: device may be mid-boot; swallow and retry. } await new Promise((r) => setTimeout(r, 1_000)); } - throw new Error(`Timed out waiting for ${serial} to finish booting`); + throw new FailureError(`Timed out waiting for ${serial} to finish booting`, { + error_code: FAILURE_CODES.ANDROID_ADB_BOOT_TIMEOUT, + failure_stage: "android_wait_for_boot", + failure_area: "tool_server", + error_kind: "timeout", + }); } export interface AvdInfo { diff --git a/packages/tool-server/src/utils/android-devtools-client.ts b/packages/tool-server/src/utils/android-devtools-client.ts index 9f00573e..926c0eaa 100644 --- a/packages/tool-server/src/utils/android-devtools-client.ts +++ b/packages/tool-server/src/utils/android-devtools-client.ts @@ -1,5 +1,6 @@ import * as net from "node:net"; import * as readline from "node:readline"; +import { FAILURE_CODES, FailureError } from "@argent/registry"; /** * Newline-delimited JSON socket client for the android-devtools helper. @@ -51,7 +52,15 @@ export function connectAndroidDevtoolsClient( closed = true; for (const req of pending.values()) { clearTimeout(req.timer); - req.reject(error ?? new Error("AndroidDevtools client closed")); + req.reject( + error ?? + new FailureError("AndroidDevtools client closed", { + error_code: FAILURE_CODES.ANDROID_DEVTOOLS_RPC_CLIENT_CLOSED, + failure_stage: "android_devtools_rpc_client", + failure_area: "tool_server", + error_kind: "subprocess", + }) + ); } pending.clear(); try { @@ -80,7 +89,14 @@ export function connectAndroidDevtoolsClient( if (parsed.error) { const message = parsed.error.message ?? "Unknown helper error"; const type = parsed.error.type ?? "HelperError"; - req.reject(new Error(`${type}: ${message}`)); + req.reject( + new FailureError(`${type}: ${message}`, { + error_code: FAILURE_CODES.ANDROID_DEVTOOLS_RPC_ERROR, + failure_stage: "android_devtools_rpc_response", + failure_area: "tool_server", + error_kind: "subprocess", + }) + ); } else { req.resolve(parsed.result); } @@ -90,7 +106,16 @@ export function connectAndroidDevtoolsClient( resolve({ request(method: string, params: Record = {}): Promise { const send = (): Promise => { - if (closed) return Promise.reject(new Error("AndroidDevtools client closed")); + if (closed) { + return Promise.reject( + new FailureError("AndroidDevtools client closed", { + error_code: FAILURE_CODES.ANDROID_DEVTOOLS_RPC_CLIENT_CLOSED, + failure_stage: "android_devtools_rpc_request", + failure_area: "tool_server", + error_kind: "subprocess", + }) + ); + } const id = nextId++; const timeoutMs = method === "getHierarchy" ? LONG_RPC_TIMEOUT_MS : DEFAULT_RPC_TIMEOUT_MS; @@ -98,7 +123,12 @@ export function connectAndroidDevtoolsClient( const timer = setTimeout(() => { pending.delete(id); rejectReq( - new Error(`AndroidDevtools RPC ${method} timed out after ${timeoutMs}ms`) + new FailureError(`AndroidDevtools RPC ${method} timed out after ${timeoutMs}ms`, { + error_code: FAILURE_CODES.ANDROID_DEVTOOLS_RPC_TIMEOUT, + failure_stage: "android_devtools_rpc_request", + failure_area: "tool_server", + error_kind: "timeout", + }) ); }, timeoutMs); pending.set(id, { @@ -135,7 +165,16 @@ export function connectAndroidDevtoolsClient( }); socket.once("close", () => { - if (!closed) cleanup(new Error("AndroidDevtools socket closed unexpectedly")); + if (!closed) { + cleanup( + new FailureError("AndroidDevtools socket closed unexpectedly", { + error_code: FAILURE_CODES.ANDROID_DEVTOOLS_SOCKET_CLOSED, + failure_stage: "android_devtools_socket", + failure_area: "tool_server", + error_kind: "subprocess", + }) + ); + } }); }); } diff --git a/packages/tool-server/src/utils/android-screen.ts b/packages/tool-server/src/utils/android-screen.ts index 09257677..0d821413 100644 --- a/packages/tool-server/src/utils/android-screen.ts +++ b/packages/tool-server/src/utils/android-screen.ts @@ -1,3 +1,4 @@ +import { FAILURE_CODES, FailureError } from "@argent/registry"; import { adbShell } from "./adb"; export interface AndroidScreenSize { @@ -25,12 +26,22 @@ export async function getAndroidScreenSize(serial: string): Promise { const timer = setTimeout(() => { this.pendingBindings.delete(id); - reject(new Error(`Binding response for requestId=${id} timed out`)); + reject( + new FailureError(`Binding response for requestId=${id} timed out`, { + error_code: FAILURE_CODES.DEBUGGER_CDP_BINDING_TIMEOUT, + failure_stage: "debugger_cdp_binding", + failure_area: "tool_server", + error_kind: "timeout", + }) + ); }, timeout); this.pendingBindings.set(id, { resolve, reject, timer }); @@ -259,8 +271,14 @@ export class CDPClient { clearTimeout(req.timer); if (msg.error) { req.reject( - new Error( - ((msg.error as Record).message as string) ?? JSON.stringify(msg.error) + new FailureError( + ((msg.error as Record).message as string) ?? JSON.stringify(msg.error), + { + error_code: FAILURE_CODES.DEBUGGER_CDP_PROTOCOL_ERROR, + failure_stage: "debugger_cdp_protocol", + failure_area: "tool_server", + error_kind: "unknown", + } ) ); } else { diff --git a/packages/tool-server/src/utils/debugger/discovery.ts b/packages/tool-server/src/utils/debugger/discovery.ts index d20e26a4..d44fc954 100644 --- a/packages/tool-server/src/utils/debugger/discovery.ts +++ b/packages/tool-server/src/utils/debugger/discovery.ts @@ -1,3 +1,5 @@ +import { FAILURE_CODES, FailureError } from "@argent/registry"; + export interface CDPTarget { id: string; title: string; @@ -24,19 +26,43 @@ export async function discoverMetro(port: number): Promise { const statusRes = await fetch(`http://localhost:${port}/status`); const statusText = await statusRes.text(); if (!statusText.includes("packager-status:running")) { - throw new Error(`Metro at port ${port} is not running (got: ${statusText.slice(0, 100)})`); + throw new FailureError( + `Metro at port ${port} is not running (got: ${statusText.slice(0, 100)})`, + { + error_code: FAILURE_CODES.DEBUGGER_METRO_NOT_RUNNING, + failure_stage: "debugger_discover_metro_status", + failure_area: "tool_server", + error_kind: "network", + } + ); } const projectRoot = statusRes.headers.get("X-React-Native-Project-Root") ?? ""; if (!projectRoot) { - throw new Error(`Metro at port ${port} did not return X-React-Native-Project-Root header`); + throw new FailureError( + `Metro at port ${port} did not return X-React-Native-Project-Root header`, + { + error_code: FAILURE_CODES.DEBUGGER_METRO_PROJECT_ROOT_MISSING, + failure_stage: "debugger_discover_metro_project_root", + failure_area: "tool_server", + error_kind: "network", + } + ); } const listRes = await fetch(`http://localhost:${port}/json/list`); const targets = (await listRes.json()) as CDPTarget[]; if (!targets?.length) { - throw new Error(`Metro at port ${port} has no CDP targets — is a React Native app connected?`); + throw new FailureError( + `Metro at port ${port} has no CDP targets — is a React Native app connected?`, + { + error_code: FAILURE_CODES.DEBUGGER_METRO_NO_TARGETS, + failure_stage: "debugger_discover_metro_targets", + failure_area: "tool_server", + error_kind: "network", + } + ); } return { port, projectRoot, targets }; diff --git a/packages/tool-server/src/utils/format-error.ts b/packages/tool-server/src/utils/format-error.ts index d0fffe0e..123497f3 100644 --- a/packages/tool-server/src/utils/format-error.ts +++ b/packages/tool-server/src/utils/format-error.ts @@ -1,3 +1,5 @@ +import { FAILURE_CODES, FailureError, type FailureCode } from "@argent/registry"; + /** * Walk the error `.cause` chain and build a single message containing the * top-level message plus any unique root-cause details the agent wouldn't @@ -43,10 +45,36 @@ export function toSimulatorNetworkError( const combined = `${err.message} ${causeMsg}`; const suffix = fallbackHint ? ` ${fallbackHint}` : ""; + const networkError = ( + message: string, + errorCode: FailureCode, + errorKind: "timeout" | "network" = "network" + ): Error => + new FailureError( + message, + { + error_code: errorCode, + failure_stage: "simulator_server_network", + failure_area: "tool_server", + error_kind: errorKind, + network_failure: + errorCode === FAILURE_CODES.SIMULATOR_NETWORK_TIMEOUT + ? "timeout" + : errorCode === FAILURE_CODES.SIMULATOR_NETWORK_CONNECTION_REFUSED + ? "connection_refused" + : errorCode === FAILURE_CODES.SIMULATOR_NETWORK_CONNECTION_RESET + ? "connection_reset" + : "other", + }, + { cause: err } + ); + if (err.name === "AbortError" || combined.includes("aborted")) { - return new Error( + return networkError( `${toolLabel} timed out — simulator-server at ${apiUrl} did not respond in time. ` + - `The simulator may be unresponsive.${suffix}` + `The simulator may be unresponsive.${suffix}`, + FAILURE_CODES.SIMULATOR_NETWORK_TIMEOUT, + "timeout" ); } @@ -55,22 +83,25 @@ export function toSimulatorNetworkError( "the next simulator tool call (gesture, screenshot, etc.) starts a fresh simulator-server process."; if (combined.includes("ECONNREFUSED")) { - return new Error( + return networkError( `${toolLabel} failed: cannot connect to simulator-server (connection refused at ${apiUrl}). ` + - `The native server process may have crashed or not be listening yet. ${recovery}` + `The native server process may have crashed or not be listening yet. ${recovery}`, + FAILURE_CODES.SIMULATOR_NETWORK_CONNECTION_REFUSED ); } if (combined.includes("ECONNRESET") || combined.includes("socket hang up")) { - return new Error( + return networkError( `${toolLabel} failed: connection to simulator-server was reset (${apiUrl}). ` + - `The server may have crashed mid-request. ${recovery}` + `The server may have crashed mid-request. ${recovery}`, + FAILURE_CODES.SIMULATOR_NETWORK_CONNECTION_RESET ); } - return new Error( + return networkError( `${toolLabel} failed: network error communicating with simulator-server at ${apiUrl}: ` + `${err.message}${causeMsg ? ` (${causeMsg})` : ""}. ` + - `Verify the simulator is booted. ${recovery}${suffix}` + `Verify the simulator is booted. ${recovery}${suffix}`, + FAILURE_CODES.SIMULATOR_NETWORK_ERROR ); } diff --git a/packages/tool-server/src/utils/ios-profiler/startup.ts b/packages/tool-server/src/utils/ios-profiler/startup.ts index 3157c2a0..7e714d9c 100644 --- a/packages/tool-server/src/utils/ios-profiler/startup.ts +++ b/packages/tool-server/src/utils/ios-profiler/startup.ts @@ -1,4 +1,5 @@ import type { ChildProcess } from "child_process"; +import { FAILURE_CODES, FailureError, subprocessFailureMetadata } from "@argent/registry"; import type { NotifyHandle } from "./notify"; export interface WaitForXctraceReadyOptions { @@ -54,16 +55,46 @@ export function waitForXctraceReady( child.on("exit", (code, signal) => { settle(() => reject( - new Error( + new FailureError( `xctrace record exited before recording started (code=${code}, signal=${signal}). ` + - `stderr: ${stderrBuffer.trim() || ""}` + `stderr: ${stderrBuffer.trim() || ""}`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_XCTRACE_READY_EXITED, + failure_stage: "native_profiler_xctrace_ready", + failure_area: "tool_server", + error_kind: "subprocess", + failure_command: "xctrace", + ...(typeof code === "number" ? { failure_exit_code: code } : {}), + ...(signal === "SIGABRT" || + signal === "SIGHUP" || + signal === "SIGINT" || + signal === "SIGKILL" || + signal === "SIGQUIT" || + signal === "SIGTERM" + ? { failure_signal: signal } + : {}), + } ) ) ); }); child.on("error", (err: Error) => { - settle(() => reject(new Error(`Failed to start xctrace: ${err.message}`))); + settle(() => + reject( + new FailureError( + `Failed to start xctrace: ${err.message}`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_XCTRACE_PROCESS_ERROR, + failure_stage: "native_profiler_xctrace_process", + failure_area: "tool_server", + error_kind: "subprocess", + ...subprocessFailureMetadata(err, "xctrace"), + }, + { cause: err } + ) + ) + ); }); const startupTimer = setTimeout(() => { @@ -74,9 +105,17 @@ export function waitForXctraceReady( // already dead } reject( - new Error( + new FailureError( `xctrace record did not start within ${timeoutMs} ms. ` + - `Last stderr: ${stderrBuffer.trim() || ""}` + `Last stderr: ${stderrBuffer.trim() || ""}`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_XCTRACE_READY_TIMEOUT, + failure_stage: "native_profiler_xctrace_ready", + failure_area: "tool_server", + error_kind: "timeout", + failure_command: "xctrace", + failure_signal: "SIGKILL", + } ) ); }); diff --git a/packages/tool-server/src/utils/native-target-app.ts b/packages/tool-server/src/utils/native-target-app.ts index f75de2ef..cc8f2b9b 100644 --- a/packages/tool-server/src/utils/native-target-app.ts +++ b/packages/tool-server/src/utils/native-target-app.ts @@ -1,3 +1,4 @@ +import { FAILURE_CODES, FailureError } from "@argent/registry"; import type { NativeAppState, NativeDevtoolsApi } from "../blueprints/native-devtools"; export type NativeTargetResolutionSource = @@ -46,9 +47,15 @@ export async function resolveNativeTargetApp( const connectedApps = await inspectConnectedNativeApps(api); if (connectedApps.length === 0) { - throw new Error( + throw new FailureError( "No native-devtools-connected apps are available for auto-targeting. " + - "Launch or restart the app first, provide bundleId explicitly, or use screenshot to inspect visible Home/system UI." + "Launch or restart the app first, provide bundleId explicitly, or use screenshot to inspect visible Home/system UI.", + { + error_code: FAILURE_CODES.NATIVE_TARGET_NO_CONNECTED_APPS, + failure_stage: "native_target_auto_resolve", + failure_area: "tool_server", + error_kind: "not_found", + } ); } @@ -61,10 +68,16 @@ export async function resolveNativeTargetApp( }; } const app = connectedApps[0]; - throw new Error( + throw new FailureError( "A single native-devtools-connected app is available, but it is not foreground-like and may be backgrounded while home/system UI is visible.\n" + `- ${app.bundleId} (applicationState=${app.applicationState}, foregroundActiveScenes=${app.foregroundActiveSceneCount}, foregroundInactiveScenes=${app.foregroundInactiveSceneCount})\n` + - "Provide bundleId explicitly if you still want to target this app." + "Provide bundleId explicitly if you still want to target this app.", + { + error_code: FAILURE_CODES.NATIVE_TARGET_SINGLE_APP_NOT_FOREGROUND, + failure_stage: "native_target_auto_resolve", + failure_area: "tool_server", + error_kind: "validation", + } ); } @@ -78,9 +91,15 @@ export async function resolveNativeTargetApp( `- ${app.bundleId} (applicationState=${app.applicationState}, foregroundActiveScenes=${app.foregroundActiveSceneCount}, foregroundInactiveScenes=${app.foregroundInactiveSceneCount})` ) .join("\n"); - throw new Error( + throw new FailureError( "Multiple native-devtools-connected apps are available and none can be identified as uniquely frontmost.\n" + `${appList}\n` + - "Provide bundleId explicitly." + "Provide bundleId explicitly.", + { + error_code: FAILURE_CODES.NATIVE_TARGET_MULTIPLE_APPS_AMBIGUOUS, + failure_stage: "native_target_auto_resolve", + failure_area: "tool_server", + error_kind: "validation", + } ); } diff --git a/packages/tool-server/src/utils/simulator-client.ts b/packages/tool-server/src/utils/simulator-client.ts index d8c2b5d7..e90b925e 100644 --- a/packages/tool-server/src/utils/simulator-client.ts +++ b/packages/tool-server/src/utils/simulator-client.ts @@ -1,4 +1,5 @@ import WebSocket from "ws"; +import { FAILURE_CODES, FailureError } from "@argent/registry"; import type { SimulatorServerApi } from "../blueprints/simulator-server"; import { toSimulatorNetworkError } from "./format-error"; @@ -66,9 +67,16 @@ async function simulatorPost( try { body = (await res.json()) as T; } catch { - throw new Error( + throw new FailureError( `${toolLabel} failed: simulator-server returned non-JSON response (HTTP ${res.status}). ` + - `The server may be in a bad state. Restart the simulator-server and retry.` + `The server may be in a bad state. Restart the simulator-server and retry.`, + { + error_code: FAILURE_CODES.SIMULATOR_NON_JSON_RESPONSE, + failure_stage: "simulator_server_parse_response", + failure_area: "tool_server", + error_kind: "network", + network_failure: "invalid_response", + } ); } @@ -106,15 +114,29 @@ export async function httpScreenshot( if (!res.ok) { const serverMsg = resBody.error ?? `HTTP ${res.status}`; - throw new Error( + throw new FailureError( `Screenshot failed: ${serverMsg}. ` + - `Ensure the simulator is booted and the simulator-server is running.` + `Ensure the simulator is booted and the simulator-server is running.`, + { + error_code: FAILURE_CODES.SIMULATOR_HTTP_ERROR_RESPONSE, + failure_stage: "simulator_screenshot_http_response", + failure_area: "tool_server", + error_kind: "network", + network_failure: "invalid_response", + } ); } if (resBody.url == null || resBody.path == null) { - throw new Error( + throw new FailureError( "Screenshot failed: server response missing url or path. " + - "The simulator-server may be misconfigured. Try restarting it." + "The simulator-server may be misconfigured. Try restarting it.", + { + error_code: FAILURE_CODES.SIMULATOR_MISSING_RESPONSE_FIELDS, + failure_stage: "simulator_screenshot_response_shape", + failure_area: "tool_server", + error_kind: "network", + network_failure: "invalid_response", + } ); } return { url: resBody.url, path: resBody.path }; diff --git a/packages/tool-server/test/http-dep-gate.test.ts b/packages/tool-server/test/http-dep-gate.test.ts index 4d63f779..a3eedfba 100644 --- a/packages/tool-server/test/http-dep-gate.test.ts +++ b/packages/tool-server/test/http-dep-gate.test.ts @@ -71,6 +71,64 @@ describe("http dependency gate", () => { expect(res.body.error).toMatch(/android-platform-tools/); }); + it("records a static failure signal when a pre-flight dep is missing", async () => { + stubProbe(["adb"]); + const recordFailure = vi.fn(); + const registry = new Registry(); + registry.registerTool({ + id: "android-thing", + requires: ["adb"], + zodSchema: z.object({}), + services: () => ({}), + async execute() { + throw new Error("execute should have been skipped"); + }, + }); + const { app } = createHttpApp(registry, { recordFailure }); + const res = await request(app).post("/tools/android-thing").send({}); + expect(res.status).toBe(424); + expect(recordFailure).toHaveBeenCalledWith( + "android-thing", + {}, + { + error_code: "HTTP_DEPENDENCY_PREFLIGHT_MISSING", + failure_stage: "http_dependency_preflight", + failure_area: "http", + error_kind: "dependency_missing", + }, + expect.any(Number) + ); + }); + + it("records a static failure signal when request validation fails", async () => { + stubProbe([]); + const recordFailure = vi.fn(); + const registry = new Registry(); + registry.registerTool({ + id: "validated-thing", + zodSchema: z.object({ count: z.number() }), + services: () => ({}), + async execute() { + throw new Error("execute should have been skipped"); + }, + }); + const { app } = createHttpApp(registry, { recordFailure }); + const res = await request(app).post("/tools/validated-thing").send({ count: "nope" }); + expect(res.status).toBe(400); + expect(recordFailure).toHaveBeenCalledWith( + "validated-thing", + {}, + { + error_code: "HTTP_ZOD_VALIDATION_FAILED", + failure_stage: "http_zod_validation", + failure_area: "http", + error_kind: "validation", + }, + expect.any(Number) + ); + expect(JSON.stringify(recordFailure.mock.calls)).not.toContain("Expected number"); + }); + it("invokes the tool normally when declared deps are present", async () => { stubProbe([]); const registry = new Registry();