From 0908170e5201cfae4123b64881bc57afe0807600 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 22 Jun 2026 23:42:14 -0700 Subject: [PATCH] Config errors are now more obvious --- .../migration.sql | 2 + apps/backend/prisma/schema.prisma | 1 + .../internal/config/pushed-error/route.tsx | 79 +++ .../latest/internal/projects/current/crud.tsx | 20 +- .../app/api/latest/projects/current/crud.tsx | 11 +- apps/backend/src/lib/config.tsx | 90 ++- apps/backend/src/lib/projects.tsx | 2 + .../sessions/[sessionId]/heartbeat/route.ts | 15 +- .../config-file.test.ts | 2 +- .../config-sync-error-format.ts | 45 ++ .../manager.test.ts | 29 + .../remote-development-environment/manager.ts | 402 +++++++++++-- .../endpoints/api/v1/internal/config.test.ts | 100 ++++ docs-mintlify/openapi/admin.json | 11 + docs-mintlify/openapi/client.json | 11 + docs-mintlify/openapi/server.json | 11 + packages/cli/src/commands/dev.test.ts | 56 +- packages/cli/src/commands/dev.ts | 64 +- packages/shared-backend/src/index.ts | 2 +- packages/shared/src/config-authoring.ts | 4 +- .../shared/src/interface/crud/projects.ts | 12 + .../apps/implementations/admin-app-impl.ts | 6 + .../apps/implementations/client-app-impl.ts | 14 +- .../src/lib/hexclave-app/projects/index.ts | 2 + .../src/pushed-config-error-overlay/index.ts | 546 ++++++++++++++++++ 25 files changed, 1489 insertions(+), 48 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260623000000_add_branch_config_pushed_error/migration.sql create mode 100644 apps/backend/src/app/api/latest/internal/config/pushed-error/route.tsx create mode 100644 apps/dashboard/src/lib/remote-development-environment/config-sync-error-format.ts create mode 100644 apps/dashboard/src/lib/remote-development-environment/manager.test.ts create mode 100644 packages/template/src/pushed-config-error-overlay/index.ts diff --git a/apps/backend/prisma/migrations/20260623000000_add_branch_config_pushed_error/migration.sql b/apps/backend/prisma/migrations/20260623000000_add_branch_config_pushed_error/migration.sql new file mode 100644 index 000000000..7fe989b42 --- /dev/null +++ b/apps/backend/prisma/migrations/20260623000000_add_branch_config_pushed_error/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "BranchConfigOverride" +ADD COLUMN "pushedConfigError" JSONB; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 1b4d8544f..8eb12390e 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -135,6 +135,7 @@ model BranchConfigOverride { config Json source Json? + pushedConfigError Json? project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) diff --git a/apps/backend/src/app/api/latest/internal/config/pushed-error/route.tsx b/apps/backend/src/app/api/latest/internal/config/pushed-error/route.tsx new file mode 100644 index 000000000..e4886c8de --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/config/pushed-error/route.tsx @@ -0,0 +1,79 @@ +import { clearBranchConfigPushedError, setBranchConfigPushedError } from "@/lib/config"; +import { isDevelopmentEnvironmentProject } from "@/lib/development-environment"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { StatusError } from "@hexclave/shared/dist/utils/errors"; + +const pushedConfigErrorMessageSchema = yupString().max(1_000).defined(); + +async function assertRdeProject(projectId: string): Promise { + if (!(await isDevelopmentEnvironmentProject(projectId))) { + throw new StatusError(StatusError.Forbidden, "Pushed config errors can only be set for development-environment projects."); + } +} + +export const PUT = createSmartRouteHandler({ + metadata: { + hidden: true, + summary: "Set pushed config error", + description: "Attach the latest pushed config error to the current development-environment branch config override.", + tags: ["Config"], + }, + request: yupObject({ + auth: yupObject({ + type: serverOrHigherAuthTypeSchema, + tenancy: adaptSchema, + }).defined(), + body: yupObject({ + error_message: pushedConfigErrorMessageSchema, + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["success"]).defined(), + }), + handler: async (req) => { + await assertRdeProject(req.auth.tenancy.project.id); + await setBranchConfigPushedError({ + projectId: req.auth.tenancy.project.id, + branchId: req.auth.tenancy.branchId, + error: { + message: req.body.error_message, + }, + }); + return { + statusCode: 200, + bodyType: "success", + }; + }, +}); + +export const DELETE = createSmartRouteHandler({ + metadata: { + hidden: true, + summary: "Clear pushed config error", + description: "Clear the latest pushed config error on the current development-environment branch config override.", + tags: ["Config"], + }, + request: yupObject({ + auth: yupObject({ + type: serverOrHigherAuthTypeSchema, + tenancy: adaptSchema, + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["success"]).defined(), + }), + handler: async (req) => { + await assertRdeProject(req.auth.tenancy.project.id); + await clearBranchConfigPushedError({ + projectId: req.auth.tenancy.project.id, + branchId: req.auth.tenancy.branchId, + }); + return { + statusCode: 200, + bodyType: "success", + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/projects/current/crud.tsx b/apps/backend/src/app/api/latest/internal/projects/current/crud.tsx index b227efa59..d03a02fba 100644 --- a/apps/backend/src/app/api/latest/internal/projects/current/crud.tsx +++ b/apps/backend/src/app/api/latest/internal/projects/current/crud.tsx @@ -1,4 +1,4 @@ -import { renderedOrganizationConfigToProjectCrud } from "@/lib/config"; +import { getBranchConfigPushedError, getDevelopmentEnvironmentConfigWarnings, renderedOrganizationConfigToProjectCrud } from "@/lib/config"; import { createOrUpdateProjectWithLegacyConfig } from "@/lib/projects"; import { getTenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; @@ -26,12 +26,30 @@ export const projectsCrudHandlers = createLazyProxy(() => createCrudHandlers(pro const tenancy = await getTenancy(auth.tenancy.id) ?? throwErr("Tenancy not found after project update?"); // since we updated the project, we need to re-fetch the new tenancy config return { ...project, + pushed_config_error: await getBranchConfigPushedError({ + projectId: auth.project.id, + branchId: auth.tenancy.branchId, + }), + config_warnings: await getDevelopmentEnvironmentConfigWarnings({ + projectId: auth.project.id, + branchId: auth.tenancy.branchId, + organizationId: auth.tenancy.organization?.id ?? null, + }), config: renderedOrganizationConfigToProjectCrud(tenancy.config), }; }, onRead: async ({ auth }) => { return { ...auth.project, + pushed_config_error: await getBranchConfigPushedError({ + projectId: auth.project.id, + branchId: auth.tenancy.branchId, + }), + config_warnings: await getDevelopmentEnvironmentConfigWarnings({ + projectId: auth.project.id, + branchId: auth.tenancy.branchId, + organizationId: auth.tenancy.organization?.id ?? null, + }), config: renderedOrganizationConfigToProjectCrud(auth.tenancy.config), }; }, diff --git a/apps/backend/src/app/api/latest/projects/current/crud.tsx b/apps/backend/src/app/api/latest/projects/current/crud.tsx index 5b28d4d0c..1d92facbc 100644 --- a/apps/backend/src/app/api/latest/projects/current/crud.tsx +++ b/apps/backend/src/app/api/latest/projects/current/crud.tsx @@ -1,4 +1,4 @@ -import { renderedOrganizationConfigToProjectCrud } from "@/lib/config"; +import { getBranchConfigPushedError, getDevelopmentEnvironmentConfigWarnings, renderedOrganizationConfigToProjectCrud } from "@/lib/config"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { clientProjectsCrud } from "@hexclave/shared/dist/interface/crud/projects"; import { yupObject } from "@hexclave/shared/dist/schema-fields"; @@ -9,6 +9,15 @@ export const clientProjectsCrudHandlers = createLazyProxy(() => createCrudHandle onRead: async ({ auth }) => { return { ...auth.project, + pushed_config_error: await getBranchConfigPushedError({ + projectId: auth.project.id, + branchId: auth.tenancy.branchId, + }), + config_warnings: await getDevelopmentEnvironmentConfigWarnings({ + projectId: auth.project.id, + branchId: auth.tenancy.branchId, + organizationId: auth.tenancy.organization?.id ?? null, + }), config: renderedOrganizationConfigToProjectCrud(auth.tenancy.config), }; }, diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 83c754c4a..e7144d0b3 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -11,11 +11,17 @@ import { Result } from "@hexclave/shared/dist/utils/results"; import { deindent, stringCompare } from "@hexclave/shared/dist/utils/strings"; import * as yup from "yup"; import { RawQuery, globalPrismaClient, rawQuery } from "../prisma-client"; -import { DEVELOPMENT_ENVIRONMENT_ENV_CONFIG_BLOCKED_MESSAGE, getEnvironmentConfigWriteBlockReason } from "./development-environment"; +import { DEVELOPMENT_ENVIRONMENT_ENV_CONFIG_BLOCKED_MESSAGE, getEnvironmentConfigWriteBlockReason, isDevelopmentEnvironmentProject } from "./development-environment"; import { getLocalEmulatorFilePath, isLocalEmulatorEnabled, isLocalEmulatorProject, readConfigFromFile, writeConfigToFile } from "./local-emulator"; import { listPermissionDefinitionsFromConfig } from "./permissions"; type BranchConfigSourceApi = yup.InferType; +export type BranchConfigPushedError = { + message: string, +}; +export type ConfigWarning = { + message: string, +}; type ProjectOptions = { projectId: string }; type BranchOptions = ProjectOptions & { branchId: string }; @@ -316,6 +322,88 @@ export async function setBranchConfigOverride(options: { config: newConfig, }, }); + await clearBranchConfigPushedError({ + projectId: options.projectId, + branchId: options.branchId, + }); +} + +function isBranchConfigPushedError(value: unknown): value is BranchConfigPushedError { + return ( + value != null && + typeof value === "object" && + !Array.isArray(value) && + "message" in value && + typeof value.message === "string" + ); +} + +export async function getBranchConfigPushedError(options: { + projectId: string, + branchId: string, +}): Promise { + const rows = await globalPrismaClient.$replica().$queryRaw>(Prisma.sql` + SELECT "pushedConfigError" + FROM "BranchConfigOverride" + WHERE "projectId" = ${options.projectId} + AND "branchId" = ${options.branchId} + LIMIT 1 + `); + const error = rows[0]?.pushedConfigError; + return isBranchConfigPushedError(error) ? error : null; +} + +export async function setBranchConfigPushedError(options: { + projectId: string, + branchId: string, + error: BranchConfigPushedError, +}): Promise { + const errorJson = JSON.stringify(options.error); + await globalPrismaClient.$executeRaw(Prisma.sql` + INSERT INTO "BranchConfigOverride" ("projectId", "branchId", "config", "pushedConfigError", "updatedAt") + VALUES (${options.projectId}, ${options.branchId}, '{}'::jsonb, ${errorJson}::jsonb, NOW()) + ON CONFLICT ("projectId", "branchId") DO UPDATE SET + "pushedConfigError" = EXCLUDED."pushedConfigError", + "updatedAt" = NOW() + `); +} + +export async function clearBranchConfigPushedError(options: { + projectId: string, + branchId: string, +}): Promise { + await globalPrismaClient.$executeRaw(Prisma.sql` + UPDATE "BranchConfigOverride" + SET "pushedConfigError" = NULL, + "updatedAt" = NOW() + WHERE "projectId" = ${options.projectId} + AND "branchId" = ${options.branchId} + AND "pushedConfigError" IS NOT NULL + `); +} + +export async function getDevelopmentEnvironmentConfigWarnings(options: { + projectId: string, + branchId: string, + organizationId: string | null, +}): Promise { + if (!(await isDevelopmentEnvironmentProject(options.projectId))) { + return []; + } + + const incompleteConfig = await rawQuery(globalPrismaClient, getIncompleteOrganizationConfigQuery({ + projectId: options.projectId, + branchId: options.branchId, + organizationId: options.organizationId, + })); + const warnings = await getIncompleteConfigWarnings(organizationConfigSchema, incompleteConfig); + if (warnings.status === "ok") { + return []; + } + return warnings.error + .split("\n") + .filter((message) => message.length > 0) + .map((message) => ({ message })); } /** diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 1638db5fc..ef0340bd9 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -76,6 +76,8 @@ export function getProjectQuery(projectId: string): RawQuery event.status === "success" + ? { + config_file_path: event.configFilePath, + status: "success", + created_at_millis: event.createdAtMillis, + } + : { + config_file_path: event.configFilePath, + status: "error", + error_message: event.errorMessage, + created_at_millis: event.createdAtMillis, + }), }); } diff --git a/apps/dashboard/src/lib/remote-development-environment/config-file.test.ts b/apps/dashboard/src/lib/remote-development-environment/config-file.test.ts index 41ea2fbcf..654964de5 100644 --- a/apps/dashboard/src/lib/remote-development-environment/config-file.test.ts +++ b/apps/dashboard/src/lib/remote-development-environment/config-file.test.ts @@ -211,7 +211,7 @@ describe("remote development environment config file", () => { const { readConfigFile } = await import("./config-file"); await expect(readConfigFile(configPath)).rejects.toThrow( - `Failed to load config file ${configPath}. If your config imports a value (e.g. defineHexclaveConfig) from a framework package such as "@hexclave/next", import it from that package's lightweight "/config" entrypoint instead` + `Failed to load config file ${configPath}.` ); }); diff --git a/apps/dashboard/src/lib/remote-development-environment/config-sync-error-format.ts b/apps/dashboard/src/lib/remote-development-environment/config-sync-error-format.ts new file mode 100644 index 000000000..63ba2a42f --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/config-sync-error-format.ts @@ -0,0 +1,45 @@ +import { errorToNiceString } from "@hexclave/shared/dist/utils/errors"; + +const CONFIG_SYNC_CLI_ERROR_MAX_LENGTH = 500; + +function stripAnsiEscapeSequences(value: string): string { + return value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ""); +} + +function normalizeSingleLineErrorMessage(value: string): string { + return stripAnsiEscapeSequences(value) + .replace(/\s+/g, " ") + .trim() + .replace(/^Error:\s+/, ""); +} + +function truncateConfigSyncCliErrorMessage(value: string): string { + if (value.length <= CONFIG_SYNC_CLI_ERROR_MAX_LENGTH) { + return value; + } + return `${value.slice(0, CONFIG_SYNC_CLI_ERROR_MAX_LENGTH - 1)}…`; +} + +function extractConfigSyncErrorSummary(error: unknown): string { + const rawMessage = error instanceof Error && error.message.length > 0 + ? error.message + : errorToNiceString(error); + + const messageBeforeStack = rawMessage + .split(/\n\s*Stack:/)[0] + .split(/\n\s*Cause:/)[0]; + const failedRequestMatch = /Failed to send request to \S+:\s*(\d{3})\s+([\s\S]+)/.exec(messageBeforeStack); + const summary = failedRequestMatch == null + ? messageBeforeStack + : failedRequestMatch[2]; + const normalizedSummary = normalizeSingleLineErrorMessage(summary); + if (normalizedSummary.length === 0) { + return "The config file could not be synced."; + } + return truncateConfigSyncCliErrorMessage(normalizedSummary); +} + +export function formatConfigSyncErrorForCli(configFilePath: string, error: unknown): string { + const summary = extractConfigSyncErrorSummary(error); + return `Config file error: ${summary} Please check your config file at ${configFilePath}.`; +} diff --git a/apps/dashboard/src/lib/remote-development-environment/manager.test.ts b/apps/dashboard/src/lib/remote-development-environment/manager.test.ts new file mode 100644 index 000000000..faa928e0d --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/manager.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { formatConfigSyncErrorForCli } from "./config-sync-error-format"; + +describe("formatConfigSyncErrorForCli", () => { + it("extracts the backend config validation message without stack details", () => { + const message = formatConfigSyncErrorForCli( + "/Users/example/app/hexclave.config.ts", + new Error(`Failed to send request to http://127.0.0.1:9202/api/v1/internal/config/override/branch: 400 The key "abcd" is not valid (nested object not found in schema: "abcd"). + Stack: + at sendClientRequestInner (/internal/chunk.js:1:1) + Cause: + Response { "status": 400, "headers": Headers {} }`), + ); + + expect(message).toBe(`Config file error: The key "abcd" is not valid (nested object not found in schema: "abcd"). Please check your config file at /Users/example/app/hexclave.config.ts.`); + }); + + it("keeps generic sync failures concise and actionable", () => { + const message = formatConfigSyncErrorForCli( + "/Users/example/app/hexclave.config.ts", + new Error(`Unexpected token '}' in JSON + Stack: + at readConfigFile (/internal/chunk.js:1:1)`), + ); + + expect(message).toBe("Config file error: Unexpected token '}' in JSON Please check your config file at /Users/example/app/hexclave.config.ts."); + expect(message).not.toContain("Stack:"); + }); +}); diff --git a/apps/dashboard/src/lib/remote-development-environment/manager.ts b/apps/dashboard/src/lib/remote-development-environment/manager.ts index 1de2ad4ac..cb43dda24 100644 --- a/apps/dashboard/src/lib/remote-development-environment/manager.ts +++ b/apps/dashboard/src/lib/remote-development-environment/manager.ts @@ -12,6 +12,7 @@ import { randomUUID } from "crypto"; import { watch, type FSWatcher } from "fs"; import { basename, dirname } from "path"; import { peekRemoteDevelopmentEnvironmentBrowserSecretConfirmationCodeForCli } from "./browser-secret"; +import { formatConfigSyncErrorForCli } from "./config-sync-error-format"; import { ensureConfigFileExists, readConfigFile, @@ -33,6 +34,7 @@ const STARTUP_EMPTY_SESSION_GRACE_MS = 20_000; const SYNC_DEBOUNCE_MS = 500; const CONFIG_SYNC_FORMAT_VERSION = 2; const LOG_PREFIX = "[Stack RDE]"; +const CONFIG_SYNC_DUPLICATE_EVENT_SUPPRESSION_MS = 2_000; export class RemoteDevelopmentEnvironmentApiUnavailableError extends Error { constructor(apiBaseUrl: string, cause: unknown) { @@ -45,8 +47,34 @@ type ActiveSession = { configFilePath: string, lastHeartbeatMs: number, receivedFirstHeartbeat: boolean, + lastDeliveredConfigSyncEventId: number, }; +type ConfigSyncEventBase = { + id: number, + configFilePath: string, + createdAtMillis: number, +}; + +type ConfigSyncEvent = ConfigSyncEventBase & ({ + status: "success", +} | { + status: "error", + errorMessage: string, +}); + +type RemoteDevelopmentEnvironmentConfigSyncEventBase = { + configFilePath: string, + createdAtMillis: number, +}; + +export type RemoteDevelopmentEnvironmentConfigSyncEvent = RemoteDevelopmentEnvironmentConfigSyncEventBase & ({ + status: "success", +} | { + status: "error", + errorMessage: string, +}); + type RemoteDevelopmentEnvironmentDebugSession = { sessionId: string, configFilePath: string, @@ -65,6 +93,13 @@ type RemoteDevelopmentEnvironmentDebugSnapshot = { watchedConfigFiles: string[], pendingSyncConfigFiles: string[], syncErrors: { configFilePath: string, error: string }[], + pendingConfigSyncEvents: { + id: number, + configFilePath: string, + status: "success" | "error", + errorMessage?: string, + createdAgoMs: number, + }[], synchronouslyUpdatingConfigFiles: string[], localDashboards: { port: number, @@ -92,7 +127,11 @@ type RemoteDevelopmentEnvironmentGlobals = { sessions: Map, watchers: Map, syncTimers: Map, + activeConfigSyncs: Map>, syncErrors: Map, + configSyncEvents: ConfigSyncEvent[], + lastConfigSyncEventByConfigFile: Map, + nextConfigSyncEventId: number, synchronouslyUpdatingConfigFiles: Set, shutdownTimerStarted: boolean, startedAtMs: number, @@ -114,7 +153,11 @@ function getGlobals(): RemoteDevelopmentEnvironmentGlobals { sessions: new Map(), watchers: new Map(), syncTimers: new Map(), + activeConfigSyncs: new Map(), syncErrors: new Map(), + configSyncEvents: [], + lastConfigSyncEventByConfigFile: new Map(), + nextConfigSyncEventId: 1, synchronouslyUpdatingConfigFiles: new Set(), shutdownTimerStarted: false, startedAtMs: performance.now(), @@ -125,19 +168,89 @@ function getGlobals(): RemoteDevelopmentEnvironmentGlobals { } function logRemoteDevelopmentEnvironment(message: string, details?: Record): void { + const prefix = `[${new Date().toISOString()}] ${LOG_PREFIX}`; if (details == null) { - console.log(`${LOG_PREFIX} ${message}`); + console.log(`${prefix} ${message}`); return; } - console.log(`${LOG_PREFIX} ${message}`, details); + console.log(`${prefix} ${message}`, details); } function warnRemoteDevelopmentEnvironment(message: string, details?: Record): void { + const prefix = `[${new Date().toISOString()}] ${LOG_PREFIX}`; if (details == null) { - console.warn(`${LOG_PREFIX} ${message}`); + console.warn(`${prefix} ${message}`); return; } - console.warn(`${LOG_PREFIX} ${message}`, details); + console.warn(`${prefix} ${message}`, details); +} + +function configSyncEventsMatchForCliDeduplication(a: ConfigSyncEvent, b: { status: "success" } | { status: "error", errorMessage: string }): boolean { + if (a.status !== b.status) { + return false; + } + if (a.status === "success") { + return true; + } + if (b.status !== "error") { + return false; + } + return a.errorMessage === b.errorMessage; +} + +function recordConfigSyncEvent(configFilePath: string, event: { status: "success" } | { status: "error", errorMessage: string }): void { + const state = getGlobals(); + const createdAtMillis = Date.now(); + const lastEvent = state.lastConfigSyncEventByConfigFile.get(configFilePath); + const isDuplicate = lastEvent != null && + configSyncEventsMatchForCliDeduplication(lastEvent, event) && + createdAtMillis - lastEvent.createdAtMillis < CONFIG_SYNC_DUPLICATE_EVENT_SUPPRESSION_MS; + if (isDuplicate) { + logRemoteDevelopmentEnvironment("Suppressing duplicate config sync CLI notification", { + configFilePath, + status: event.status, + duplicateWindowMs: CONFIG_SYNC_DUPLICATE_EVENT_SUPPRESSION_MS, + }); + return; + } + const baseEvent = { + id: state.nextConfigSyncEventId, + configFilePath, + createdAtMillis, + }; + const configSyncEvent: ConfigSyncEvent = event.status === "success" + ? { ...baseEvent, status: "success" } + : { ...baseEvent, status: "error", errorMessage: event.errorMessage }; + state.nextConfigSyncEventId += 1; + state.configSyncEvents.push(configSyncEvent); + state.lastConfigSyncEventByConfigFile.set(configFilePath, configSyncEvent); + if (state.configSyncEvents.length > 100) { + state.configSyncEvents.splice(0, state.configSyncEvents.length - 100); + } +} + +function drainConfigSyncEventsForSession(session: ActiveSession): RemoteDevelopmentEnvironmentConfigSyncEvent[] { + const state = getGlobals(); + const pendingEvents = state.configSyncEvents.filter((event) => ( + event.configFilePath === session.configFilePath && + event.id > session.lastDeliveredConfigSyncEventId + )); + if (pendingEvents.length === 0) { + return []; + } + session.lastDeliveredConfigSyncEventId = pendingEvents[pendingEvents.length - 1].id; + return pendingEvents.map((event) => event.status === "success" + ? { + configFilePath: event.configFilePath, + status: "success", + createdAtMillis: event.createdAtMillis, + } + : { + configFilePath: event.configFilePath, + status: "error", + errorMessage: event.errorMessage, + createdAtMillis: event.createdAtMillis, + }); } function errorLooksLikeApiConnectionFailure(error: unknown): boolean { @@ -401,7 +514,99 @@ async function syncRemoteDevelopmentEnvironmentOnboardingStatus( return onboardingStatus; } -async function syncConfigToRemote(configFilePath: string): Promise { +async function updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath: string, errorMessage: string | null): Promise { + const state = readRemoteDevelopmentEnvironmentState(); + const project = state.projectsByConfigPath[configFilePath]; + if (project == null || state.anonymousRefreshToken == null) { + warnRemoteDevelopmentEnvironment("Skipping pushed config error update because local state is incomplete", { + configFilePath, + hasProject: project != null, + hasAnonymousRefreshToken: state.anonymousRefreshToken != null, + }); + return; + } + + const app = createInternalApp(project.apiBaseUrl, state.anonymousRefreshToken); + const user = await app.getUser({ or: "anonymous" }); + const ownedProject = (await user.listOwnedProjects()).find((p) => p.id === project.projectId); + if (ownedProject == null) { + warnRemoteDevelopmentEnvironment("Skipping pushed config error update because the project is not owned by the anonymous user", { + projectId: project.projectId, + configFilePath, + }); + return; + } + + logRemoteDevelopmentEnvironment(errorMessage == null ? "Clearing pushed config error on development-environment project" : "Setting pushed config error on development-environment project", { + projectId: project.projectId, + configFilePath, + }); + const response = await getStackAppRequestInternals(ownedProject.app).sendRequest( + "/internal/config/pushed-error", + errorMessage == null + ? { method: "DELETE" } + : { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + error_message: errorMessage, + }), + }, + "admin", + ); + if (!response.ok) { + throw new Error(`Failed to ${errorMessage == null ? "clear" : "set"} development-environment pushed config error (${response.status}): ${await response.text()}`); + } +} + +type ConfigSyncResult = { + onboardingStatus: ProjectOnboardingStatus | undefined, + pushedConfig: boolean, +}; + +async function runConfigSyncSerialized(configFilePath: string, operationName: string, operation: () => Promise): Promise { + const state = getGlobals(); + while (true) { + const activeSync = state.activeConfigSyncs.get(configFilePath); + if (activeSync == null) { + break; + } + logRemoteDevelopmentEnvironment("Waiting for active config sync before starting another one", { + configFilePath, + operationName, + }); + await activeSync; + } + + let releaseActiveSync: () => void = () => {}; + const activeSync = new Promise((resolvePromise) => { + releaseActiveSync = resolvePromise; + }); + state.activeConfigSyncs.set(configFilePath, activeSync); + try { + logRemoteDevelopmentEnvironment("Starting serialized config sync operation", { + configFilePath, + operationName, + }); + return await operation(); + } finally { + if (state.activeConfigSyncs.get(configFilePath) === activeSync) { + state.activeConfigSyncs.delete(configFilePath); + } + releaseActiveSync(); + logRemoteDevelopmentEnvironment("Finished serialized config sync operation", { + configFilePath, + operationName, + }); + } +} + +async function syncConfigToRemote(configFilePath: string): Promise { + logRemoteDevelopmentEnvironment("Starting config sync", { + configFilePath, + }); const state = readRemoteDevelopmentEnvironmentState(); const project = state.projectsByConfigPath[configFilePath]; if (project == null || state.anonymousRefreshToken == null) { @@ -410,31 +615,56 @@ async function syncConfigToRemote(configFilePath: string): Promise p.id === project.projectId); if (ownedProject == null) { warnRemoteDevelopmentEnvironment("Skipping config sync because the project is not owned by the anonymous user", { projectId: project.projectId, configFilePath, }); - return undefined; + return { onboardingStatus: undefined, pushedConfig: false }; } - const onboardingStatus = await syncRemoteDevelopmentEnvironmentOnboardingStatus(ownedProject, showOnboarding); - if (project.lastSyncedConfigHash === configHash) { - return onboardingStatus; - } - - logRemoteDevelopmentEnvironment("Syncing config to development-environment project", { + logRemoteDevelopmentEnvironment("Syncing onboarding status before config push", { projectId: project.projectId, configFilePath, showOnboarding, }); + const onboardingStatus = await syncRemoteDevelopmentEnvironmentOnboardingStatus(ownedProject, showOnboarding); + if (project.lastSyncedConfigHash === configHash) { + logRemoteDevelopmentEnvironment("Skipping config push because remote config hash is current", { + projectId: project.projectId, + configFilePath, + configHash, + onboardingStatus, + }); + await updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath, null); + return { onboardingStatus, pushedConfig: false }; + } + + logRemoteDevelopmentEnvironment("Sending config push request to development-environment project", { + projectId: project.projectId, + configFilePath, + configHash, + showOnboarding, + }); await ownedProject.replaceConfigOverride("branch", config); updateRemoteDevelopmentEnvironmentState((current) => ({ @@ -451,10 +681,11 @@ async function syncConfigToRemote(configFilePath: string): Promise { state.syncTimers.delete(configFilePath); runAsynchronously( async () => { - await syncConfigToRemote(configFilePath); - state.syncErrors.delete(configFilePath); - }, - { - onError: (error) => { + try { + const result = await runConfigSyncSerialized( + configFilePath, + "debounced local file change", + () => syncConfigToRemote(configFilePath), + ); + state.syncErrors.delete(configFilePath); + if (result.pushedConfig) { + recordConfigSyncEvent(configFilePath, { status: "success" }); + } + } catch (error) { + const errorMessage = errorToNiceString(error); + const cliErrorMessage = formatConfigSyncErrorForCli(configFilePath, error); warnRemoteDevelopmentEnvironment("Config sync failed", { configFilePath, - error: errorToNiceString(error), + error: errorMessage, }); - state.syncErrors.set(configFilePath, error); - }, + state.syncErrors.set(configFilePath, error instanceof Error ? error : new Error(errorMessage)); + await updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath, cliErrorMessage) + .catch((pushedConfigErrorUpdateError: unknown) => { + warnRemoteDevelopmentEnvironment("Failed to update pushed config error after config sync failure", { + configFilePath, + error: errorToNiceString(pushedConfigErrorUpdateError), + }); + }); + recordConfigSyncEvent(configFilePath, { + status: "error", + errorMessage: cliErrorMessage, + }); + } + }, + { + noErrorLogging: true, }, ); }, SYNC_DEBOUNCE_MS); @@ -493,16 +749,23 @@ function scheduleSync(configFilePath: string): void { state.syncTimers.set(configFilePath, timer); } -async function syncConfigToRemoteNow(configFilePath: string): Promise { +async function syncConfigToRemoteNow(configFilePath: string): Promise { const state = getGlobals(); const pendingTimer = state.syncTimers.get(configFilePath); if (pendingTimer != null) { clearTimeout(pendingTimer); state.syncTimers.delete(configFilePath); + logRemoteDevelopmentEnvironment("Canceled pending debounced config sync before immediate sync", { + configFilePath, + }); } - const onboardingStatus = await syncConfigToRemote(configFilePath); + const result = await runConfigSyncSerialized( + configFilePath, + "immediate sync", + () => syncConfigToRemote(configFilePath), + ); state.syncErrors.delete(configFilePath); - return onboardingStatus; + return result; } function ensureWatcher(configFilePath: string): void { @@ -545,6 +808,7 @@ function ensureShutdownTimer(): void { uptimeMs: Math.round(now - state.startedAtMs), watchedConfigFiles: state.watchers.size, pendingSyncs: state.syncTimers.size, + activeConfigSyncs: state.activeConfigSyncs.size, syncErrors: state.syncErrors.size, activeOperations: state.activeOperations, hasClosedSession: state.hasClosedSession, @@ -587,14 +851,43 @@ export async function registerRemoteDevelopmentEnvironmentSession(options: { anonymousRefreshToken: state.anonymousRefreshToken, }).catch((error: unknown) => throwApiUnavailableIfConnectionFailure(options.apiBaseUrl, error)); ensureWatcher(configFilePath); - const onboardingStatus = await syncConfigToRemoteNow(configFilePath) - .catch((error: unknown) => throwApiUnavailableIfConnectionFailure(options.apiBaseUrl, error)); const sessionId = randomUUID(); - getGlobals().sessions.set(sessionId, { + const globals = getGlobals(); + globals.sessions.set(sessionId, { configFilePath, lastHeartbeatMs: performance.now(), receivedFirstHeartbeat: false, + lastDeliveredConfigSyncEventId: globals.nextConfigSyncEventId - 1, }); + let syncResult: ConfigSyncResult = { onboardingStatus: undefined, pushedConfig: false }; + try { + syncResult = await syncConfigToRemoteNow(configFilePath); + } catch (error) { + if (errorLooksLikeApiConnectionFailure(error)) { + globals.sessions.delete(sessionId); + throw new RemoteDevelopmentEnvironmentApiUnavailableError(options.apiBaseUrl, error); + } + const errorMessage = errorToNiceString(error); + warnRemoteDevelopmentEnvironment("Initial config sync failed", { + sessionId, + projectId: project.projectId, + configFilePath, + error: errorMessage, + }); + globals.syncErrors.set(configFilePath, error instanceof Error ? error : new Error(errorMessage)); + const cliErrorMessage = formatConfigSyncErrorForCli(configFilePath, error); + await updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath, cliErrorMessage) + .catch((pushedConfigErrorUpdateError: unknown) => { + warnRemoteDevelopmentEnvironment("Failed to update pushed config error after initial config sync failure", { + configFilePath, + error: errorToNiceString(pushedConfigErrorUpdateError), + }); + }); + recordConfigSyncEvent(configFilePath, { + status: "error", + errorMessage: cliErrorMessage, + }); + } logRemoteDevelopmentEnvironment("Registered CLI session", { sessionId, projectId: project.projectId, @@ -605,25 +898,29 @@ export async function registerRemoteDevelopmentEnvironmentSession(options: { sessionId, env: envVarsForProject(project), projectId: project.projectId, - onboardingOutstanding: onboardingStatus != null && onboardingStatus !== "completed", + onboardingOutstanding: syncResult.onboardingStatus != null && syncResult.onboardingStatus !== "completed", }; } finally { endOperation(); } } -export function heartbeatRemoteDevelopmentEnvironmentSession(sessionId: string): boolean { +export function heartbeatRemoteDevelopmentEnvironmentSession(sessionId: string): { + configSyncEvents: RemoteDevelopmentEnvironmentConfigSyncEvent[], +} | null { assertRemoteDevelopmentEnvironmentEnabled(); const session = getGlobals().sessions.get(sessionId); if (session == null) { warnRemoteDevelopmentEnvironment("Received heartbeat for unknown session", { sessionId, }); - return false; + return null; } session.lastHeartbeatMs = performance.now(); session.receivedFirstHeartbeat = true; - return true; + return { + configSyncEvents: drainConfigSyncEventsForSession(session), + }; } export function getPendingRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode(): { code: string, expiresAtMillis: number } | null { @@ -703,6 +1000,20 @@ export function getRemoteDevelopmentEnvironmentDebugSnapshot(): RemoteDevelopmen configFilePath, error: errorToNiceString(error), })), + pendingConfigSyncEvents: globals.configSyncEvents.map((event) => event.status === "success" + ? { + id: event.id, + configFilePath: event.configFilePath, + status: "success", + createdAgoMs: Math.max(0, unixNow - event.createdAtMillis), + } + : { + id: event.id, + configFilePath: event.configFilePath, + status: "error", + errorMessage: event.errorMessage, + createdAgoMs: Math.max(0, unixNow - event.createdAtMillis), + }), synchronouslyUpdatingConfigFiles: [...globals.synchronouslyUpdatingConfigFiles], localDashboards: Object.values(state.localDashboardsByPort ?? {}) .filter((dashboard) => dashboard != null) @@ -781,7 +1092,28 @@ export async function applyRemoteDevelopmentEnvironmentConfigUpdate(options: { state.synchronouslyUpdatingConfigFiles.delete(configFilePath); }, SYNC_DEBOUNCE_MS).unref(); } - await syncConfigToRemoteNow(configFilePath); + try { + const result = await syncConfigToRemoteNow(configFilePath); + if (result.pushedConfig) { + recordConfigSyncEvent(configFilePath, { status: "success" }); + } + } catch (error) { + const errorMessage = errorToNiceString(error); + state.syncErrors.set(configFilePath, error instanceof Error ? error : new Error(errorMessage)); + const cliErrorMessage = formatConfigSyncErrorForCli(configFilePath, error); + await updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath, cliErrorMessage) + .catch((pushedConfigErrorUpdateError: unknown) => { + warnRemoteDevelopmentEnvironment("Failed to update pushed config error after dashboard config sync failure", { + configFilePath, + error: errorToNiceString(pushedConfigErrorUpdateError), + }); + }); + recordConfigSyncEvent(configFilePath, { + status: "error", + errorMessage: cliErrorMessage, + }); + throw error; + } } logRemoteDevelopmentEnvironment("Applied config update from local dashboard", { sessionId: options.sessionId, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts index da78433d2..66a29dfa3 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts @@ -1312,6 +1312,106 @@ describe("pushConfig and updateConfig behavior", () => { }); }); +describe("pushed config errors", () => { + it("sets pushed config errors only for development-environment projects and clears them after a successful branch config push", async ({ expect }) => { + await Project.createAndSwitch(); + const nonRdeResponse = await niceBackendFetch("/api/v1/internal/config/pushed-error", { + method: "PUT", + accessType: "server", + body: { + error_message: "Config file error: Example failure. Please check your config file.", + }, + }); + expect(nonRdeResponse.status).toBe(403); + + await Project.createAndSwitch({ + is_development_environment: true, + }); + const setErrorResponse = await niceBackendFetch("/api/v1/internal/config/pushed-error", { + method: "PUT", + accessType: "server", + body: { + error_message: "Config file error: The key \"abcd\" is not valid. Please check your config file.", + }, + }); + expect(setErrorResponse.status).toBe(200); + + const projectWithErrorResponse = await niceBackendFetch("/api/v1/projects/current", { + accessType: "client", + }); + expect(projectWithErrorResponse.status).toBe(200); + expect(projectWithErrorResponse.body.pushed_config_error).toEqual({ + message: "Config file error: The key \"abcd\" is not valid. Please check your config file.", + }); + + const pushConfigResponse = await niceBackendFetch("/api/v1/internal/config/override/branch", { + method: "PUT", + accessType: "server", + body: { + config_string: JSON.stringify({ + "auth.allowSignUp": true, + }), + source: { type: "pushed-from-unknown" }, + }, + }); + expect(pushConfigResponse.status).toBe(200); + + const projectAfterPushResponse = await niceBackendFetch("/api/v1/projects/current", { + accessType: "client", + }); + expect(projectAfterPushResponse.status).toBe(200); + expect(projectAfterPushResponse.body.pushed_config_error).toBeNull(); + }); +}); + +describe("config warnings", () => { + it("only exposes config warnings on development-environment project responses", async ({ expect }) => { + const warningConfig = { + "auth.oauth.providers.google.type": "google", + }; + + await Project.createAndSwitch(); + const nonRdePushResponse = await niceBackendFetch("/api/v1/internal/config/override/branch", { + method: "PUT", + accessType: "server", + body: { + config_string: JSON.stringify(warningConfig), + source: { type: "pushed-from-unknown" }, + }, + }); + expect(nonRdePushResponse.status).toBe(200); + + const nonRdeProjectResponse = await niceBackendFetch("/api/v1/projects/current", { + accessType: "client", + }); + expect(nonRdeProjectResponse.status).toBe(200); + expect(nonRdeProjectResponse.body.config_warnings).toEqual([]); + + await Project.createAndSwitch({ + is_development_environment: true, + }); + const rdePushResponse = await niceBackendFetch("/api/v1/internal/config/override/branch", { + method: "PUT", + accessType: "server", + body: { + config_string: JSON.stringify(warningConfig), + source: { type: "pushed-from-unknown" }, + }, + }); + expect(rdePushResponse.status).toBe(200); + + const rdeProjectResponse = await niceBackendFetch("/api/v1/projects/current", { + accessType: "client", + }); + expect(rdeProjectResponse.status).toBe(200); + expect(rdeProjectResponse.body.config_warnings).toEqual([ + { + message: "Dot-notation key \"auth.oauth.providers.google.type\" will be silently ignored because it references non-existent parent \"auth.oauth.providers.google\". Instead of dot notation, use nested object notation like this: { \"auth.oauth.providers.google\": { \"type\": ... } }", + }, + ]); + }); +}); + describe("test helpers", () => { it("Project.updateConfig helper sets environment level config", async ({ expect }) => { diff --git a/docs-mintlify/openapi/admin.json b/docs-mintlify/openapi/admin.json index d577870a2..d7850fbc5 100644 --- a/docs-mintlify/openapi/admin.json +++ b/docs-mintlify/openapi/admin.json @@ -6425,6 +6425,17 @@ "type": "string", "example": "MyMusic", "description": "Human-readable project display name. This is not a unique identifier." + }, + "pushed_config_error": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] } }, "required": [ diff --git a/docs-mintlify/openapi/client.json b/docs-mintlify/openapi/client.json index 84c7a01b8..b2acdd55d 100644 --- a/docs-mintlify/openapi/client.json +++ b/docs-mintlify/openapi/client.json @@ -4411,6 +4411,17 @@ "type": "string", "example": "MyMusic", "description": "Human-readable project display name. This is not a unique identifier." + }, + "pushed_config_error": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] } }, "required": [ diff --git a/docs-mintlify/openapi/server.json b/docs-mintlify/openapi/server.json index 645cdce0c..a39a81288 100644 --- a/docs-mintlify/openapi/server.json +++ b/docs-mintlify/openapi/server.json @@ -5728,6 +5728,17 @@ "type": "string", "example": "MyMusic", "description": "Human-readable project display name. This is not a unique identifier." + }, + "pushed_config_error": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] } }, "required": [ diff --git a/packages/cli/src/commands/dev.test.ts b/packages/cli/src/commands/dev.test.ts index 487bfa577..53cb648db 100644 --- a/packages/cli/src/commands/dev.test.ts +++ b/packages/cli/src/commands/dev.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { recordLocalDashboardProcess } from "../lib/dev-env-state.js"; -import { devDashboardCommandFromEnv, isVersionNewer, killLocalDashboard, processExists, shouldRestartDashboard } from "./dev.js"; +import { configErrorLogPrefix, devDashboardCommandFromEnv, isHeartbeatResponse, isVersionNewer, killLocalDashboard, processExists, shouldRestartDashboard } from "./dev.js"; describe("isVersionNewer", () => { it("compares core versions numerically", () => { @@ -88,6 +88,60 @@ describe("devDashboardCommandFromEnv", () => { }); }); +describe("configErrorLogPrefix", () => { + it("highlights the config error badge when color is supported", () => { + expect(configErrorLogPrefix(true)).toBe("[Hexclave] \x1b[41;37;1m[CONFIG ERROR]\x1b[0m "); + }); + + it("keeps a readable plain-text badge without color support", () => { + expect(configErrorLogPrefix(false)).toBe("[Hexclave] [CONFIG ERROR] "); + }); +}); + +describe("isHeartbeatResponse", () => { + it("accepts config sync events from the dashboard heartbeat", () => { + expect(isHeartbeatResponse({ + ok: true, + config_sync_events: [ + { + config_file_path: "/app/hexclave.config.ts", + status: "success", + created_at_millis: 1_718_000_000_000, + }, + { + config_file_path: "/app/hexclave.config.ts", + status: "error", + error_message: "Could not reach the API.", + created_at_millis: 1_718_000_000_001, + }, + ], + })).toBe(true); + }); + + it("rejects malformed config sync events", () => { + expect(isHeartbeatResponse({ + ok: true, + config_sync_events: [ + { + config_file_path: "/app/hexclave.config.ts", + status: "pending", + created_at_millis: 1_718_000_000_000, + }, + ], + })).toBe(false); + expect(isHeartbeatResponse({ + ok: true, + config_sync_events: [ + { + config_file_path: "/app/hexclave.config.ts", + status: "error", + created_at_millis: 1_718_000_000_000, + }, + ], + })).toBe(false); + }); +}); + describe("killLocalDashboard", () => { let tempDir: string; diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 26ac5ee08..047414051 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -28,10 +28,23 @@ type SessionResponse = { onboarding_outstanding: boolean, }; +type ConfigSyncEventBase = { + config_file_path: string, + created_at_millis: number, +}; + +type ConfigSyncEvent = ConfigSyncEventBase & ({ + status: "success", +} | { + status: "error", + error_message: string, +}); + type HeartbeatResponse = { ok: true, browser_secret_confirmation_code?: string, browser_secret_confirmation_code_expires_at_millis?: number, + config_sync_events?: ConfigSyncEvent[], }; const HEARTBEAT_INTERVAL_MS = 5_000; @@ -128,6 +141,19 @@ function logDev(message: string): void { console.warn(`${LOG_PREFIX}${message}`); } +function stderrSupportsAnsiColor(): boolean { + return process.stderr.isTTY && process.env.NO_COLOR == null && process.env.TERM !== "dumb"; +} + +export function configErrorLogPrefix(supportsColor = stderrSupportsAnsiColor()): string { + const label = supportsColor ? "\x1b[41;37;1m[CONFIG ERROR]\x1b[0m" : "[CONFIG ERROR]"; + return `${LOG_PREFIX}${label} `; +} + +function logDevConfigError(message: string): void { + console.warn(`${configErrorLogPrefix()}${message}`); +} + function openUrlInBrowser(url: string): boolean { try { if (process.platform === "darwin") { @@ -610,7 +636,28 @@ function isSessionResponse(value: unknown): value is SessionResponse { ); } -function isHeartbeatResponse(value: unknown): value is HeartbeatResponse { +function isConfigSyncEvent(value: unknown): value is ConfigSyncEvent { + if ( + !isRecord(value) || + !("config_file_path" in value) || + typeof value.config_file_path !== "string" || + !("status" in value) || + !("created_at_millis" in value) || + typeof value.created_at_millis !== "number" + ) { + return false; + } + if (value.status === "success") { + return true; + } + return ( + value.status === "error" && + "error_message" in value && + typeof value.error_message === "string" + ); +} + +export function isHeartbeatResponse(value: unknown): value is HeartbeatResponse { return ( typeof value === "object" && value !== null && @@ -624,6 +671,10 @@ function isHeartbeatResponse(value: unknown): value is HeartbeatResponse { ( !("browser_secret_confirmation_code_expires_at_millis" in value) || typeof value.browser_secret_confirmation_code_expires_at_millis === "number" + ) && + ( + !("config_sync_events" in value) || + (Array.isArray(value.config_sync_events) && value.config_sync_events.every(isConfigSyncEvent)) ) ); } @@ -639,6 +690,16 @@ function logBrowserSecretConfirmationCode(response: HeartbeatResponse): void { : `Dashboard browser confirmation code: ${response.browser_secret_confirmation_code} (expires in ${expiresInSeconds}s)`); } +function logConfigSyncEvents(response: HeartbeatResponse): void { + for (const event of response.config_sync_events ?? []) { + if (event.status === "success") { + logDev(`Config synced to development environment project: ${event.config_file_path}`); + } else { + logDevConfigError(`Config sync failed for ${event.config_file_path}: ${event.error_message}`); + } + } +} + function pendingBrowserSecretConfirmationCodeFromState(port: number): HeartbeatResponse | null { const pending = readDevEnvState().pendingBrowserSecretConfirmationCodesByPort?.[String(port)]; if (pending == null || pending.expiresAtMillis <= Date.now()) { @@ -928,6 +989,7 @@ async function heartbeatUntilStopped(sessionState: DashboardSessionState, option logBrowserSecretConfirmationCode(heartbeatBody); lastLoggedConfirmationCode = heartbeatBody.browser_secret_confirmation_code; } + logConfigSyncEvents(heartbeatBody); } } diff --git a/packages/shared-backend/src/index.ts b/packages/shared-backend/src/index.ts index 044ab1edf..9ea34211c 100644 --- a/packages/shared-backend/src/index.ts +++ b/packages/shared-backend/src/index.ts @@ -76,7 +76,7 @@ export async function readConfigFile(configFilePath: string): Promise<{ config: // user-facing message we're deliberately replacing. captureError("shared-backend/readConfigFile", error); throw new Error( - `Failed to load config file ${configFilePath}. If your config imports a value (e.g. defineHexclaveConfig) from a framework package such as "@hexclave/next", import it from that package's lightweight "/config" entrypoint instead, which doesn't load the framework runtime:\n\n import { defineHexclaveConfig } from "@hexclave/next/config";\n`, + `Failed to load config file ${configFilePath}.`, ); } if (!isConfigModule(configModule)) { diff --git a/packages/shared/src/config-authoring.ts b/packages/shared/src/config-authoring.ts index 1539216aa..1aadb5a60 100644 --- a/packages/shared/src/config-authoring.ts +++ b/packages/shared/src/config-authoring.ts @@ -27,11 +27,11 @@ type StrictStackConfig = : T; /** @deprecated Use `defineHexclaveConfig` from the `@hexclave/*` package instead — same symbol, new brand name. See https://docs.hexclave.com/migration. */ -export function defineStackConfig(config: StrictStackConfig): T { +export function defineStackConfig(config: StrictStackConfig): StackConfig { return config; } // Hexclave alias — separate function so it does not inherit the deprecation tag. -export function defineHexclaveConfig(config: StrictStackConfig): T { +export function defineHexclaveConfig(config: StrictStackConfig): HexclaveConfig { return config; } diff --git a/packages/shared/src/interface/crud/projects.ts b/packages/shared/src/interface/crud/projects.ts index 2feaa8df5..605667572 100644 --- a/packages/shared/src/interface/crud/projects.ts +++ b/packages/shared/src/interface/crud/projects.ts @@ -32,6 +32,14 @@ const enabledOAuthProviderSchema = yupObject({ id: schemaFields.oauthIdSchema.defined(), }); +const pushedConfigErrorSchema = yupObject({ + message: yupString().defined(), +}).defined(); + +const configWarningSchema = yupObject({ + message: yupString().defined(), +}).defined(); + const onboardingConfigChoiceValues = ["create-new", "link-existing"] as const; const onboardingSignInMethodValues = ["credential", "magicLink", "passkey", "google", "github", "microsoft"] as const; const onboardingPaymentsCountryValues = ["US", "OTHER"] as const; @@ -89,6 +97,8 @@ export const projectsCrudAdminReadSchema = yupObject({ owner_team_id: schemaFields.yupString().nullable().defined(), onboarding_status: schemaFields.projectOnboardingStatusSchema.defined(), onboarding_state: projectOnboardingStateSchema.nullable().optional(), + pushed_config_error: pushedConfigErrorSchema.nullable().defined(), + config_warnings: yupArray(configWarningSchema).defined(), /** @deprecated */ config: yupObject({ allow_localhost: schemaFields.projectAllowLocalhostSchema.defined(), @@ -117,6 +127,8 @@ export const projectsCrudAdminReadSchema = yupObject({ export const projectsCrudClientReadSchema = yupObject({ id: schemaFields.projectIdSchema.defined(), display_name: schemaFields.projectDisplayNameSchema.defined(), + pushed_config_error: pushedConfigErrorSchema.nullable().defined(), + config_warnings: yupArray(configWarningSchema).defined(), config: yupObject({ sign_up_enabled: schemaFields.projectSignUpEnabledSchema.defined(), credential_enabled: schemaFields.projectCredentialEnabledSchema.defined(), diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts index ff29c5009..c87c95676 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts @@ -196,6 +196,12 @@ export class _HexclaveAdminAppImplIncomplete ({ + message: warning.message, + })), config: { signUpEnabled: data.config.sign_up_enabled, credentialEnabled: data.config.credential_enabled, diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts index 4125c4d28..f6e789a0f 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts @@ -1,5 +1,4 @@ -import { WebAuthnError, startAuthentication, startRegistration } from "@simplewebauthn/browser"; -import { KnownError, KnownErrors, HexclaveClientInterface } from "@hexclave/shared"; +import { HexclaveClientInterface, KnownError, KnownErrors } from "@hexclave/shared"; import type { RequestListener } from "@hexclave/shared/dist/interface/client-interface"; import { ContactChannelsCrud } from "@hexclave/shared/dist/interface/crud/contact-channels"; import { CurrentUserCrud } from "@hexclave/shared/dist/interface/crud/current-user"; @@ -38,15 +37,16 @@ import { BotChallengeExecutionFailedError, BotChallengeUserCancelledError, withB import { createUrlIfValid, getRelativePart, isRelative } from "@hexclave/shared/dist/utils/urls"; import { generateUuid } from "@hexclave/shared/dist/utils/uuids"; import * as tanstackStartServerContext from "@hexclave/tanstack-start/tanstack-start-server-context"; // THIS_LINE_PLATFORM tanstack-start +import { WebAuthnError, startAuthentication, startRegistration } from "@simplewebauthn/browser"; import * as TanStackRouter from "@tanstack/react-router"; // THIS_LINE_PLATFORM tanstack-start import * as cookie from "cookie"; import * as NextNavigationUnscrambled from "next/navigation"; // import the entire module to get around some static compiler warnings emitted by Next.js in some cases | THIS_LINE_PLATFORM next import React, { useCallback, useMemo } from "react"; // THIS_LINE_PLATFORM react-like import type * as yup from "yup"; +import { envVars } from "../../../../generated/env"; import { constructRedirectUrl } from "../../../../utils/url"; import { callOAuthCallback, getNewOAuthProviderOrScopeUrl } from "../../../auth"; import { CookieHelper, createBrowserCookieHelper, createCookieHelper, createPlaceholderCookieHelper, deleteCookie, deleteCookieClient, getCookieClient, isSecure as isSecureCookieContext, saveVerifierAndState, setOrDeleteCookie, setOrDeleteCookieClient } from "../../../cookie"; -import { envVars } from "../../../../generated/env"; import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptionsToCrud } from "../../api-keys"; import { ConvexCtx, GetCurrentPartialUserOptions, GetCurrentUserOptions, HandlerUrlOptions, HandlerUrls, OAuthScopesOnSignIn, RedirectMethod, RedirectToOptions, RequestLike, ResolvedHandlerUrls, TokenStoreInit, hexclaveAppInternalsSymbol } from "../../common"; import { DeprecatedOAuthConnection, OAuthConnection } from "../../connected-accounts"; @@ -73,6 +73,7 @@ import { useAsyncCache } from "./common"; // IF_PLATFORM js-like import { mountClickmapOverlay } from "../../../../clickmap"; import { mountDevTool } from "../../../../dev-tool"; +import { mountPushedConfigErrorOverlay } from "../../../../pushed-config-error-overlay"; // END_PLATFORM let isReactServer = false; @@ -770,6 +771,7 @@ export class _HexclaveClientAppImplIncomplete ({ + message: warning.message, + })), config: { signUpEnabled: crud.config.sign_up_enabled, credentialEnabled: crud.config.credential_enabled, diff --git a/packages/template/src/lib/hexclave-app/projects/index.ts b/packages/template/src/lib/hexclave-app/projects/index.ts index 5798b23b7..07a352ea0 100644 --- a/packages/template/src/lib/hexclave-app/projects/index.ts +++ b/packages/template/src/lib/hexclave-app/projects/index.ts @@ -26,6 +26,8 @@ export type PushConfigOptions = { export type Project = { readonly id: string, readonly displayName: string, + readonly pushedConfigError: { message: string } | null, + readonly configWarnings: { message: string }[], readonly config: ProjectConfig, }; diff --git a/packages/template/src/pushed-config-error-overlay/index.ts b/packages/template/src/pushed-config-error-overlay/index.ts new file mode 100644 index 000000000..7167ac791 --- /dev/null +++ b/packages/template/src/pushed-config-error-overlay/index.ts @@ -0,0 +1,546 @@ +// IF_PLATFORM js-like + +import { captureError } from "@hexclave/shared/dist/utils/errors"; +import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; +import { isLocalhost } from "@hexclave/shared/dist/utils/urls"; +import { envVars } from "../generated/env"; +import { getInPageUiBaseCSS } from "../in-page-ui/base-styles"; +import { canMountIntoDom, getGlobalUiInstance, h, setGlobalUiInstance, setHtml } from "../in-page-ui/dom"; +import type { StackClientApp } from "../lib/hexclave-app"; + +const GLOBAL_INSTANCE_KEY = "__hexclave-pushed-config-error-overlay"; +const MINIMIZED_STORAGE_KEY = "hexclave-pushed-config-error-minimized-key"; +const REFRESH_INTERVAL_MS = 5_000; +const HEXCLAVE_LOGO_SVG = ''; +const COPY_ICON_SVG = ''; + +type ConfigIssue = { + kind: "error" | "warning", + messages: string[], +}; + +const css = getInPageUiBaseCSS(".hexclave-config-error-overlay") + ` + .hexclave-config-error-overlay .hce-backdrop { + position: fixed; + inset: 0; + z-index: 2147483647; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(0, 0, 0, 0.46); + backdrop-filter: blur(6px); + overflow: auto; + } + + .hexclave-config-error-overlay .hce-card { + --hce-status: var(--sdt-error); + width: min(720px, calc(100vw - 32px)); + max-height: min(640px, calc(100dvh - 48px)); + border: 1px solid color-mix(in srgb, var(--hce-status) 35%, var(--sdt-border)); + border-radius: 18px; + background: var(--sdt-overlay-bg); + box-shadow: var(--sdt-shadow); + backdrop-filter: blur(18px); + display: flex; + overflow: hidden; + } + + .hexclave-config-error-overlay .hce-card-warning { + --hce-status: var(--sdt-warning); + } + + .hexclave-config-error-overlay .hce-card-inner { + padding: 18px; + width: 100%; + overflow: auto; + } + + .hexclave-config-error-overlay .hce-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + } + + .hexclave-config-error-overlay .hce-title-row { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; + } + + .hexclave-config-error-overlay .hce-logo { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 10px; + background: var(--hce-status); + color: white; + box-shadow: 0 10px 30px color-mix(in srgb, var(--hce-status) 32%, transparent); + } + + .hexclave-config-error-overlay .hce-badge { + display: inline-flex; + flex-shrink: 0; + padding: 2px 6px; + border-radius: 999px; + background: var(--hce-status); + color: white; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + .hexclave-config-error-overlay .hce-title { + color: var(--sdt-text); + margin-top: 4px; + font-size: 18px; + font-weight: 700; + line-height: 1.25; + } + + .hexclave-config-error-overlay .hce-actions { + display: flex; + gap: 4px; + } + + .hexclave-config-error-overlay .hce-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--sdt-border); + border-radius: 8px; + background: var(--sdt-bg-elevated); + color: var(--sdt-text-secondary); + cursor: pointer; + font: inherit; + line-height: 1; + vertical-align: top; + } + + .hexclave-config-error-overlay .hce-icon-btn svg { + display: block; + flex-shrink: 0; + } + + .hexclave-config-error-overlay .hce-text-btn { + align-items: center; + gap: 6px; + min-height: 28px; + padding: 0 10px; + width: auto; + font-size: 12px; + line-height: 1; + } + + .hexclave-config-error-overlay .hce-icon-btn:hover { + background: var(--sdt-bg-hover); + color: var(--sdt-text); + } + + .hexclave-config-error-overlay .hce-body { + color: var(--sdt-text-secondary); + font-size: 14px; + line-height: 1.5; + } + + .hexclave-config-error-overlay .hce-message-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 14px; + margin-bottom: 8px; + } + + .hexclave-config-error-overlay .hce-message-label { + color: var(--sdt-text); + font-size: 12px; + font-weight: 650; + } + + .hexclave-config-error-overlay .hce-message { + padding: 12px; + max-height: min(260px, max(96px, 30dvh)); + overflow: auto; + border: 1px solid var(--sdt-border-subtle); + border-radius: 10px; + background: var(--sdt-bg-subtle); + color: var(--sdt-text); + font-family: var(--sdt-font-mono); + font-size: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + .hexclave-config-error-overlay .hce-footer { + margin-top: 10px; + color: var(--sdt-text-tertiary); + font-size: 12px; + } + + .hexclave-config-error-overlay .hce-pill { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 2147483647; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px 8px 8px; + --hce-status: var(--sdt-error); + border: 1px solid color-mix(in srgb, var(--hce-status) 35%, var(--sdt-border)); + border-radius: 999px; + background: var(--sdt-overlay-bg); + box-shadow: var(--sdt-trigger-shadow); + color: var(--sdt-text); + cursor: pointer; + font: inherit; + backdrop-filter: blur(18px); + } + + .hexclave-config-error-overlay .hce-pill-warning { + --hce-status: var(--sdt-warning); + } + + .hexclave-config-error-overlay .hce-pill-logo { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 999px; + background: var(--hce-status); + color: white; + } + + @media (max-height: 520px) { + .hexclave-config-error-overlay .hce-backdrop { + align-items: flex-start; + padding: 12px; + } + + .hexclave-config-error-overlay .hce-card { + width: min(720px, calc(100vw - 24px)); + max-height: calc(100dvh - 24px); + } + + .hexclave-config-error-overlay .hce-card-inner { + padding: 12px; + } + + .hexclave-config-error-overlay .hce-header { + margin-bottom: 8px; + } + + .hexclave-config-error-overlay .hce-title { + font-size: 16px; + } + + .hexclave-config-error-overlay .hce-body { + font-size: 13px; + } + + .hexclave-config-error-overlay .hce-message { + max-height: max(80px, 24dvh); + } + } +`; + +function storageGet(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function storageSet(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + // Storage may be unavailable in private or embedded contexts. + } +} + +function storageRemove(key: string): void { + try { + localStorage.removeItem(key); + } catch { + // Storage may be unavailable in private or embedded contexts. + } +} + +function shouldMount(): boolean { + if (!canMountIntoDom()) { + return false; + } + + const nodeEnv = envVars.NODE_ENV; + if (nodeEnv !== undefined) { + return nodeEnv === "development"; + } + + try { + const url = new URL(window.location.href); + if (url.protocol === "file:") { + return true; + } + } catch { + return false; + } + return isLocalhost(window.location.href); +} + +async function copyTextToClipboard(text: string): Promise { + const clipboard: unknown = Reflect.get(navigator, "clipboard"); + const writeText = clipboard != null && typeof clipboard === "object" + ? Reflect.get(clipboard, "writeText") + : null; + if (typeof writeText === "function") { + await writeText.call(clipboard, text); + return; + } + + const textarea = h("textarea", { + style: { + position: "fixed", + left: "-9999px", + top: "0", + opacity: "0", + }, + readonly: "true", + }) as HTMLTextAreaElement; + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + const copied = document.execCommand("copy"); + textarea.remove(); + if (!copied) { + throw new Error("Browser refused to copy the config error message."); + } +} + +function buildConfigIssueAiPrompt(issue: ConfigIssue): string { + const issueLabel = issue.kind === "error" ? "error" : "warning"; + return [ + `Help me fix this Hexclave config ${issueLabel}.`, + "", + "Hexclave reminder:", + `This ${issueLabel} comes from a pushed Hexclave config file. The app may keep running with the synced Hexclave config, but I need to fix the config file mentioned in the message and save it again so Hexclave can sync the config successfully and clear the dialog. Use your web fetch tool to read https://skill.hexclave.com to learn more about Hexclave.`, + "", + issue.kind === "error" ? "Error message:" : "Warning message:", + issue.messages.join("\n"), + "", + "Please explain the likely cause, identify the config key or value I should change, and suggest the smallest safe fix.", + ].join("\n"); +} + +export function mountPushedConfigErrorOverlay(app: StackClientApp): () => void { + if (!shouldMount()) { + return () => {}; + } + + getGlobalUiInstance(GLOBAL_INSTANCE_KEY)?.cleanup(); + + const root = h("div", { className: "hexclave-config-error-overlay" }); + const style = h("style", null, css); + root.appendChild(style); + document.body.appendChild(root); + + let disposed = false; + let lastErrorKey: string | null = null; + let lastConsoleErrorKey: string | null = null; + let minimized = false; + + const render = (issue: ConfigIssue | null) => { + root.replaceChildren(style); + if (issue == null) { + lastErrorKey = null; + minimized = false; + return; + } + + const issueMessage = issue.messages.join("\n"); + const issueKey = `${app.projectId}:${issue.kind}:${issueMessage}`; + const issueLabel = issue.kind === "error" ? "error" : "warning"; + const issueTitle = issue.kind === "error" + ? "Your Hexclave config has been saved, but contains errors" + : "Your Hexclave config has been saved, but has warnings"; + const bodyText = issue.kind === "error" + ? "Your app can keep running, but Hexclave is still using the last valid config until this is fixed." + : "Your app can keep running, but part of your Hexclave config may not behave the way you expect until this is fixed."; + const footerText = issue.kind === "error" + ? "Fix the config file mentioned above and save it again. This message will disappear after the config sync succeeds." + : "Fix the config file mentioned above and save it again. This warning will disappear after Hexclave syncs a config without warnings."; + if (issueKey !== lastConsoleErrorKey) { + lastConsoleErrorKey = issueKey; + const consoleMessage = `[Hexclave] Config ${issueLabel}: ${issueMessage}`; + if (issue.kind === "error") { + console.error(consoleMessage); + } else { + console.warn(consoleMessage); + } + } + + if (issueKey !== lastErrorKey) { + lastErrorKey = issueKey; + minimized = storageGet(MINIMIZED_STORAGE_KEY) === issueKey; + } + + if (minimized) { + const logoSpan = h("span", { className: "hce-pill-logo" }); + setHtml(logoSpan, HEXCLAVE_LOGO_SVG); + root.appendChild(h("button", { + className: issue.kind === "error" ? "hce-pill" : "hce-pill hce-pill-warning", + type: "button", + onClick: () => { + minimized = false; + storageRemove(MINIMIZED_STORAGE_KEY); + render(issue); + }, + }, + logoSpan, + h("span", null, issue.kind === "error" ? "Config error" : "Config warning"))); + return; + } + + const logoSpan = h("span", { className: "hce-logo" }); + setHtml(logoSpan, HEXCLAVE_LOGO_SVG); + const copyButton = h("button", { + className: "hce-icon-btn hce-text-btn", + type: "button", + title: issue.kind === "error" ? "Copy error message" : "Copy warning message", + "aria-label": issue.kind === "error" ? "Copy config error message" : "Copy config warning message", + onClick: () => { + runAsynchronously(async () => { + await copyTextToClipboard(issueMessage); + copyButton.textContent = "Copied"; + setTimeout(() => { + setHtml(copyButton, `${COPY_ICON_SVG}Copy`); + }, 1500); + }, { + noErrorLogging: true, + onError: (copyError) => { + captureError("pushed-config-error-overlay-copy", copyError); + copyButton.textContent = "Copy failed"; + setTimeout(() => { + setHtml(copyButton, `${COPY_ICON_SVG}Copy`); + }, 1500); + }, + }); + }, + }); + setHtml(copyButton, `${COPY_ICON_SVG}Copy`); + + const aiPromptCopyButton = h("button", { + className: "hce-icon-btn", + type: "button", + title: "Copy AI prompt", + "aria-label": issue.kind === "error" ? "Copy AI prompt for config error" : "Copy AI prompt for config warning", + onClick: () => { + runAsynchronously(async () => { + await copyTextToClipboard(buildConfigIssueAiPrompt(issue)); + aiPromptCopyButton.textContent = "✓"; + setTimeout(() => { + setHtml(aiPromptCopyButton, COPY_ICON_SVG); + }, 1500); + }, { + noErrorLogging: true, + onError: (copyError) => { + captureError("pushed-config-error-overlay-copy-ai-prompt", copyError); + aiPromptCopyButton.textContent = "!"; + setTimeout(() => { + setHtml(aiPromptCopyButton, COPY_ICON_SVG); + }, 1500); + }, + }); + }, + }); + setHtml(aiPromptCopyButton, COPY_ICON_SVG); + + root.appendChild(h("div", { className: "hce-backdrop" }, + h("div", { className: issue.kind === "error" ? "hce-card" : "hce-card hce-card-warning", role: "alertdialog", "aria-modal": "true", "aria-label": `Hexclave config ${issueLabel}` }, + h("div", { className: "hce-card-inner" }, + h("div", { className: "hce-header" }, + h("div", { className: "hce-title-row" }, + logoSpan, + h("div", null, + h("span", { className: "hce-badge" }, `Config ${issueLabel}`), + h("div", { className: "hce-title" }, issueTitle), + ), + ), + h("div", { className: "hce-actions" }, + aiPromptCopyButton, + h("button", { + className: "hce-icon-btn", + type: "button", + title: "Minimize", + "aria-label": issue.kind === "error" ? "Minimize config error" : "Minimize config warning", + onClick: () => { + minimized = true; + storageSet(MINIMIZED_STORAGE_KEY, issueKey); + render(issue); + }, + }, "–"), + ), + ), + h("div", { className: "hce-body" }, + bodyText, + h("div", { className: "hce-message-header" }, + h("div", { className: "hce-message-label" }, issue.kind === "error" ? "Error message" : "Warning message"), + copyButton, + ), + h("div", { className: "hce-message" }, issueMessage), + h("div", { className: "hce-footer" }, footerText), + ), + ), + ))); + }; + + const refresh = () => { + if (disposed || !canMountIntoDom()) { + return; + } + runAsynchronously(async () => { + const project = await app.getProject(); + if (disposed) { + return; + } + render(project.pushedConfigError == null + ? project.configWarnings.length === 0 + ? null + : { kind: "warning", messages: project.configWarnings.map((warning) => warning.message) } + : { kind: "error", messages: [project.pushedConfigError.message] }); + }, { + noErrorLogging: true, + onError: (error) => { + captureError("pushed-config-error-overlay-refresh", error); + }, + }); + }; + + refresh(); + const interval = setInterval(refresh, REFRESH_INTERVAL_MS); + + const cleanup = () => { + disposed = true; + clearInterval(interval); + root.remove(); + if (getGlobalUiInstance(GLOBAL_INSTANCE_KEY)?.cleanup === cleanup) { + setGlobalUiInstance(GLOBAL_INSTANCE_KEY, null); + } + }; + setGlobalUiInstance(GLOBAL_INSTANCE_KEY, { cleanup }); + return cleanup; +} + +// END_PLATFORM