chore: address config agent review cleanup

This commit is contained in:
mantrakp04 2026-06-25 17:51:08 -07:00
parent 2558a63a81
commit 49a0c1083f
54 changed files with 1335 additions and 1381 deletions

View File

@ -5,7 +5,7 @@
* one). Every dashboard config write warm-boots from it and then clones the repo
* fresh, so you only pay the SDK install once here.
*
* cd apps/backend && pnpm run with-env:dev tsx scripts/build-config-agent-image.ts
* cd apps/backend && pnpm run with-env:dev tsx scripts/config-agent/build-image.ts
*
* Then set the printed id so config writes warm-boot from it (either spelling works;
* the env shim maps STACK_* <-> HEXCLAVE_*):
@ -13,7 +13,7 @@
* HEXCLAVE_CONFIG_AGENT_BASE_SNAPSHOT_ID=<printed id>
*
* Needs HEXCLAVE_VERCEL_SANDBOX_TOKEN (+ team/project ids) configured. Re-run this
* whenever AGENT_SDK_VERSION in repo-agent.tsx changes; old base snapshots can be
* whenever AGENT_SDK_VERSION in repo-agent.ts changes; old base snapshots can be
* deleted from the Vercel dashboard.
*/
import { buildConfigAgentBaseSnapshot } from "@/lib/config/repo-agent";

View File

@ -5,7 +5,7 @@
* GitHub Actions runner can't POST to localhost, so the `config push` CI step
* fails and the project never advances to "linked"). Run this to set the state.
*
* cd apps/backend && LINK_PROJECT_ID=<uuid> pnpm run with-env:dev tsx scripts/link-project-to-github.ts
* cd apps/backend && LINK_PROJECT_ID=<uuid> pnpm run with-env:dev tsx scripts/config-agent/link-project-to-github.ts
*
* Env (defaults shown):
* LINK_PROJECT_ID=<uuid> (REQUIRED)

View File

@ -16,7 +16,7 @@
* config directly.
*
* Run it (clickhouse + postgres must be up, i.e. the local stack running):
* pnpm --filter @hexclave/backend run with-env:dev tsx scripts/seed-config-test.ts
* pnpm --filter @hexclave/backend run with-env:dev tsx scripts/config-agent/seed-config-test.ts
*
* Override the target repo / config file via env (defaults shown):
* STACK_CONFIG_TEST_OWNER=mantrakp04

View File

@ -12,7 +12,7 @@
*
* Run from apps/backend:
* pnpm dlx dotenv-cli -e .env.development.local -e .env.development -- \
* pnpm tsx scripts/spike-orchestrator-e2e.mts
* pnpm tsx scripts/config-agent/spike-orchestrator-e2e.mts
*
* The GitHub token comes from $GITHUB_TOKEN, falling back to `gh auth token`.
*/
@ -21,7 +21,7 @@ import {
applyConfigUpdate,
commitConfigUpdate,
type GithubRepoRef,
} from "../src/lib/config/repo-agent";
} from "../../src/lib/config/repo-agent";
if (!process.env.SPIKE_OWNER || !process.env.SPIKE_REPO || !process.env.SPIKE_BRANCH) {
console.error("SPIKE_OWNER, SPIKE_REPO, and SPIKE_BRANCH must all be set explicitly.\nThis script pushes commits to a real repo — refusing to fall back to defaults.");

View File

@ -2,7 +2,7 @@ import { Prisma } from "@/generated/prisma/client";
import { Config, getInvalidConfigReason, normalize, override, removeKeysFromConfig } from "@hexclave/shared/dist/config/format";
import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, CompleteConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyBranchDefaults, applyEnvironmentDefaults, applyOrganizationDefaults, applyProjectDefaults, branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, getIncompleteConfigWarnings, migrateConfigOverride, organizationConfigSchema, projectConfigSchema, sanitizeBranchConfig, sanitizeEnvironmentConfig, sanitizeOrganizationConfig, sanitizeProjectConfig } from "@hexclave/shared/dist/config/schema";
import { ProjectsCrud } from "@hexclave/shared/dist/interface/crud/projects";
import { branchConfigSourceSchema, yupBoolean, yupMixed, yupObject, yupRecord, yupString, yupUnion } from "@hexclave/shared/dist/schema-fields";
import { branchConfigSourceSchema, type ConfigAgentSafeErrorMessage, yupBoolean, yupMixed, yupObject, yupRecord, yupString, yupUnion } from "@hexclave/shared/dist/schema-fields";
import { isTruthy } from "@hexclave/shared/dist/utils/booleans";
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
import { HexclaveAssertionError, captureError } from "@hexclave/shared/dist/utils/errors";
@ -720,7 +720,7 @@ export async function recordConfigAgentRunResult(options: {
outcome:
| { status: "success", commitUrl?: string, newCommitHash?: string }
| { status: "no-change" }
| { status: "error", error: string },
| { status: "error", error: ConfigAgentSafeErrorMessage },
}): Promise<void> {
await retryTransaction(globalPrismaClient, async (tx) => {
const rows = await tx.$queryRaw<{ source: any }[]>`

View File

@ -11,7 +11,7 @@
* so the closest analog is a single SHARED, repo-independent base snapshot that
* bakes in only the agent's OWN runtime (node + the Claude Agent SDK + git bot
* identity) never any repo or token. Build it once with
* `scripts/build-config-agent-image.ts` and point `STACK_CONFIG_AGENT_BASE_SNAPSHOT_ID`
* `scripts/config-agent/build-image.ts` and point `STACK_CONFIG_AGENT_BASE_SNAPSHOT_ID`
* at the printed id; every config write warm-boots from it. If the env var is
* unset (local/dev, or before the image is built) we cold-boot a node24 sandbox
* and install the SDK inline slower, but self-sufficient.
@ -399,7 +399,7 @@ async function bootAgentSandbox(creds: SandboxCreds): Promise<Sandbox> {
}
// ---------------------------------------------------------------------------
// Base snapshot build (one-off, via scripts/build-config-agent-image.ts)
// Base snapshot build (one-off, via scripts/config-agent/build-image.ts)
// ---------------------------------------------------------------------------
/**

View File

@ -1,6 +1,6 @@
import { StatusError } from "@hexclave/shared/dist/utils/errors";
import { describe, expect, it } from "vitest";
import { assertSafeOAuthResolvedAddress, assertSafeOAuthUrlWithoutDns, isBlockedOAuthIpAddress } from "./ssrf-protection";
import { describe, expect, it, vi } from "vitest";
import { assertSafeOAuthResolvedAddress, assertSafeOAuthUrlWithoutDns, isBlockedOAuthIpAddress, safeOAuthDnsLookup } from "./ssrf-protection";
describe("isBlockedOAuthIpAddress", () => {
it("blocks AWS metadata, loopback, and private IPv4 ranges", () => {
@ -50,3 +50,46 @@ describe("assertSafeOAuthResolvedAddress", () => {
});
});
describe("safeOAuthDnsLookup", () => {
async function withProductionOAuthSsrfProtection<T>(fn: () => Promise<T>): Promise<T> {
vi.stubEnv("NODE_ENV", "production");
try {
return await fn();
} finally {
vi.unstubAllEnvs();
}
}
it("passes blocked single-address DNS results to the callback as errors", async () => {
await withProductionOAuthSsrfProtection(async () => {
await new Promise<void>((resolve, reject) => {
safeOAuthDnsLookup("127.0.0.1", { all: false }, (error, address, family) => {
try {
expect(error).toBeInstanceOf(StatusError);
expect(address).toBe("");
expect(family).toBe(0);
resolve();
} catch (assertionError) {
reject(assertionError);
}
});
});
});
});
it("passes blocked multi-address DNS results to the callback as errors", async () => {
await withProductionOAuthSsrfProtection(async () => {
await new Promise<void>((resolve, reject) => {
safeOAuthDnsLookup("127.0.0.1", { all: true }, (error, addresses) => {
try {
expect(error).toBeInstanceOf(StatusError);
expect(addresses).toEqual([]);
resolve();
} catch (assertionError) {
reject(assertionError);
}
});
});
});
});
});

View File

@ -108,9 +108,14 @@ export async function assertSafeOAuthUrl(urlString: string): Promise<void> {
}
export function assertSafeOAuthResolvedAddress(address: string): void {
if (isBlockedOAuthIpAddress(address)) {
throw new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR);
}
const error = getUnsafeOAuthResolvedAddressError(address);
if (error != null) throw error;
}
function getUnsafeOAuthResolvedAddressError(address: string): StatusError | null {
return isBlockedOAuthIpAddress(address)
? new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR)
: null;
}
type DnsLookupCallback = (
@ -134,7 +139,11 @@ export function safeOAuthDnsLookup(hostname: string, options: dns.LookupOptions,
}
for (const address of addresses) {
assertSafeOAuthResolvedAddress(address.address);
const unsafeAddressError = getUnsafeOAuthResolvedAddressError(address.address);
if (unsafeAddressError != null) {
callback(unsafeAddressError, []);
return;
}
}
callback(null, addresses);
});
@ -148,7 +157,11 @@ export function safeOAuthDnsLookup(hostname: string, options: dns.LookupOptions,
return;
}
assertSafeOAuthResolvedAddress(address);
const unsafeAddressError = getUnsafeOAuthResolvedAddressError(address);
if (unsafeAddressError != null) {
callback(unsafeAddressError, "", 0);
return;
}
callback(null, address, family);
});
}

View File

@ -86,7 +86,7 @@ vi.mock("@/lib/env", () => ({
getPublicEnvVar: () => "false",
}));
vi.mock("@/lib/config-update", () => ({
vi.mock("@/components/config-update", () => ({
useUpdateConfig: () => mockUpdateConfig,
}));

View File

@ -19,7 +19,7 @@ import {
Typography,
} from "@/components/ui";
import { getPublicEnvVar } from "@/lib/env";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import {
ArrowsClockwiseIcon,
ChartBarIcon,

View File

@ -2,7 +2,7 @@
import Loading from "@/app/loading";
import { CursorBlastEffect } from "@hexclave/dashboard-ui-components";
import { ConfigUpdateDialogProvider } from "@/lib/config-update";
import { ConfigUpdateDialogProvider } from "@/components/config-update";
import { HexclaveRebrandModal } from "@/components/hexclave-rebrand-modal";
import { getPublicEnvVar } from '@/lib/env';
import { useStackApp, useUser } from "@hexclave/next";

View File

@ -6,7 +6,7 @@ import { useRouter } from "@/components/router";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui";
import { ALL_APPS_FRONTEND, getAppPath } from "@/lib/apps-frontend";
import { isAppEnabled } from "@/lib/apps-utils";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { AppId, getParentAppId } from "@hexclave/shared/dist/apps/apps-config";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";

View File

@ -13,7 +13,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SimpleTooltip } from "@/components/ui/simple-tooltip";
import { Textarea } from "@/components/ui/textarea";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { cn } from "@/lib/utils";
import {
CaretDownIcon,

View File

@ -18,7 +18,7 @@ import { Label } from "@/components/ui/label";
import { SimpleTooltip } from "@/components/ui/simple-tooltip";
import { Textarea } from "@/components/ui/textarea";
import { CreateDashboardPreview } from "@/components/commands/create-dashboard/create-dashboard-preview";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { AssistantRuntimeProvider, type ToolCallContentPartProps } from "@assistant-ui/react";
import {
ArrowClockwiseIcon,

View File

@ -7,7 +7,7 @@ import {
type DesignEditableGridItem,
} from "@/components/design-components";
import { Switch } from "@/components/ui";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { GearSix, KeyIcon, UsersIcon } from "@phosphor-icons/react";
import { useMemo, useState } from "react";
import { AppEnabledGuard } from "../app-enabled-guard";

View File

@ -3,7 +3,7 @@
import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import { AppStoreEntry } from "@/components/app-store-entry";
import { useRouter } from "@/components/router";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { ALL_APPS_FRONTEND, getAppPath, getDocumentationHref, type AppId } from "@/lib/apps-frontend";
import { isAppEnabled } from "@/lib/apps-utils";
import { getParentAppId } from "@hexclave/shared/dist/apps/apps-config";

View File

@ -3,7 +3,7 @@
import { InlineSaveDiscard } from "@/components/inline-save-discard";
import { ActionDialog, BrandIcons, BrowserFrame, FormControl, FormField, FormItem, FormLabel, FormMessage, InlineCode, Label, SimpleTooltip, Switch, Typography } from "@/components/ui";
import { FormDialog } from "@/components/form-dialog";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { useDashboardInternalUser } from "@/lib/dashboard-user";
import {
DesignAlert,

View File

@ -15,7 +15,7 @@ import {
type AssistantComposerApi,
} from "@/components/vibe-coding";
import { ToolCallContent } from "@/components/vibe-coding/chat-adapters";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { useDashboardUser } from "@/lib/dashboard-user";
import { cn } from "@/lib/utils";
import {

View File

@ -6,7 +6,7 @@ import { InputField } from "@/components/form-fields";
import { useRouter } from "@/components/router";
import { ActionDialog, Button, Typography } from "@/components/ui";
import { getShortcutModifierKeyLabel } from "@/lib/keyboard-shortcuts";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import {
ChartBarIcon,
PlusIcon,

View File

@ -10,7 +10,7 @@ import {
type DesignEditableGridItem,
} from "@/components/design-components";
import { ActionDialog, Label, toast } from "@/components/ui";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { ArrowLeftIcon, CopyIcon, DatabaseIcon, HashIcon, TagIcon, TrashIcon } from "@phosphor-icons/react";
import { deindent } from "@hexclave/shared/dist/utils/strings";
import { useMemo, useState } from "react";

View File

@ -6,7 +6,7 @@ import {
DesignListItemRow,
} from "@/components/design-components";
import { ActionDialog, Label, toast } from "@/components/ui";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { DatabaseIcon, PlusIcon } from "@phosphor-icons/react";
import { getUserSpecifiedIdErrorMessage, isValidUserSpecifiedId, sanitizeUserSpecifiedId } from "@hexclave/shared/dist/schema-fields";
import { typedEntries } from "@hexclave/shared/dist/utils/objects";

View File

@ -5,7 +5,7 @@ import { InlineSaveDiscard } from "@/components/inline-save-discard";
import { DesignAlert } from "@/components/design-components";
import { SettingCard, SettingSwitch } from "@/components/settings";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, ActionCell, ActionDialog, Alert, Button, Typography } from "@/components/ui";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { DataGrid, useDataGridUrlState, useDataSource, type DataGridColumnDef } from "@hexclave/dashboard-ui-components";
import { yupString } from "@hexclave/shared/dist/schema-fields";
import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors";

View File

@ -8,7 +8,7 @@ import {
DesignCard,
DesignInput,
} from "@/components/design-components";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { getPublicEnvVar } from "@/lib/env";
import { cn } from "@/lib/utils";
import { AdminEmailConfig } from "@hexclave/next";

View File

@ -5,7 +5,7 @@ import { FormDialog } from "@/components/form-dialog";
import { InputField } from "@/components/form-fields";
import { useRouter } from "@/components/router";
import { ActionDialog, Alert, AlertDescription, AlertTitle, Button, Typography } from "@/components/ui";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { cn } from "@/lib/utils";
import { CheckIcon, DeviceMobile, DeviceTablet, Monitor, Palette, Plus, Trash } from "@phosphor-icons/react";
import { DEFAULT_EMAIL_THEMES, DEFAULT_EMAIL_THEME_ID, previewTemplateSource } from "@hexclave/shared/dist/helpers/emails";

View File

@ -5,7 +5,7 @@ import { FormDialog } from "@/components/form-dialog";
import { InputField, SelectField, TextAreaField } from "@/components/form-fields";
import { ActionDialog, Alert, AlertDescription, AlertTitle, Button, SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Typography, useToast } from "@/components/ui";
import { useRouter } from "@/components/router";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { getPublicEnvVar } from "@/lib/env";
import { ArrowSquareOut, CheckCircle, Envelope, HardDrive, Sliders, WarningCircleIcon, XCircle, XIcon } from "@phosphor-icons/react";
import { AdminEmailConfig, AdminProject, AdminSentEmail, ServerUser, UserAvatar } from "@hexclave/next";

View File

@ -1,4 +1,4 @@
import { ConfigAgentRunWatcher } from "@/lib/config-agent-run";
import { ConfigAgentRunWatcher } from "@/components/config-update/config-agent-run-watcher";
import { UrlPrefetcher } from "@/lib/prefetch/url-prefetcher";
import SidebarLayout from "./sidebar-layout";
import { AdminAppProvider } from "./use-admin-app";

View File

@ -8,7 +8,7 @@ import {
DesignDialogClose,
} from "@/components/design-components";
import { Typography } from "@/components/ui";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { WarningCircle } from "@phosphor-icons/react";
import type { RestrictedReason } from "@hexclave/shared/dist/schema-fields";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";

View File

@ -20,7 +20,7 @@ import {
toast,
Typography,
} from "@/components/ui";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { CaretUpDownIcon } from "@phosphor-icons/react";
import { createDefaultDataGridState, DataGrid, useDataGridUrlState, useDataSource, type DataGridColumnDef } from "@hexclave/dashboard-ui-components";
import { KnownErrors } from "@hexclave/shared";

View File

@ -5,7 +5,7 @@ import { SelectField } from "@/components/form-fields";
import { Link } from "@/components/link";
import { StripeConnectProvider } from "@/components/payments/stripe-connect-provider";
import { ActionDialog, Button, Card, CardContent, Typography } from "@/components/ui";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { cn } from "@/lib/utils";
import { getPublicEnvVar } from "@/lib/env";
import { ArrowRightIcon, ArrowsClockwiseIcon, ChartBarIcon, FlaskIcon, ShieldIcon, WalletIcon, WarningIcon, WebhooksLogoIcon } from "@phosphor-icons/react";

View File

@ -21,7 +21,7 @@ import {
Typography,
} from "@/components/ui";
import { SubpageHeader } from "@/components/design-components/subpage-header";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { cn } from "@/lib/utils";
import { ClockIcon, HardDriveIcon, PackageIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon } from "@phosphor-icons/react";
import { CompleteConfig } from "@hexclave/shared/dist/config/schema";

View File

@ -40,7 +40,7 @@ import {
Typography,
} from "@/components/ui";
import { createDefaultDataGridState, DataGrid, useDataGridUrlState, useDataSource, type DataGridColumnDef } from "@hexclave/dashboard-ui-components";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { ArrowLeftIcon, ClockIcon, CopyIcon, CurrencyDollarIcon, DotsThreeIcon, FolderOpenIcon, GiftIcon, HardDriveIcon, PackageIcon, PencilSimpleIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TagIcon, TrashIcon, UsersIcon } from "@phosphor-icons/react";
import type { CompleteConfig } from "@hexclave/shared/dist/config/schema";
import type { Transaction, TransactionEntry } from "@hexclave/shared/dist/interface/crud/transactions";

View File

@ -24,7 +24,7 @@ import {
toast,
Typography,
} from "@/components/ui";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { cn } from "@/lib/utils";
import { ArrowSquareOutIcon, BuildingOfficeIcon, CaretDownIcon, ChatIcon, ClockIcon, CodeIcon, CopyIcon, GearIcon, HardDriveIcon, LightningIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon, UserIcon } from "@phosphor-icons/react";
import { CompleteConfig } from "@hexclave/shared/dist/config/schema";

View File

@ -5,7 +5,7 @@ import { ItemDialog } from "@/components/payments/item-dialog";
import { useRouter } from "@/components/router";
import { ActionDialog, Alert, AlertDescription, AlertTitle, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, toast } from "@/components/ui";
import { cn } from "@/lib/utils";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { DotsThreeVerticalIcon } from "@phosphor-icons/react";
import { CompleteConfig } from "@hexclave/shared/dist/config/schema";
import { useHover } from "@hexclave/shared/dist/hooks/use-hover";

View File

@ -26,7 +26,7 @@ import {
toast
} from "@/components/ui";
import { cn } from "@/lib/utils";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { DndContext, DragOverlay, useDraggable, useDroppable, type DragEndEvent, type DragStartEvent } from '@dnd-kit/core';
import { CaretUpDownIcon, CircleNotchIcon, CodeIcon, CopyIcon, DotsSixVerticalIcon, DotsThreeVerticalIcon, EyeIcon, FileTextIcon, HardDriveIcon, InfoIcon, PencilSimpleIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon, XIcon } from "@phosphor-icons/react";
import { CompleteConfig } from "@hexclave/shared/dist/config/schema";

View File

@ -2,7 +2,7 @@
import { Switch, Typography } from "@/components/ui";
import { DesignCard } from "@/components/design-components";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { cn } from "@/lib/utils";
import { LockIcon } from "@phosphor-icons/react";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";

View File

@ -1,7 +1,7 @@
"use client";
import { Switch, Typography } from "@/components/ui";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { cn } from "@/lib/utils";
import { DesignBadge, DesignCard } from "@/components/design-components";
import { FlaskIcon } from "@phosphor-icons/react";

View File

@ -22,7 +22,7 @@ import {
import { WalkthroughProvider } from "@/components/walkthrough/walkthrough-provider";
import { ALL_APPS_FRONTEND, DUMMY_ORIGIN, getAppPath, getItemPath, hasNavigationItems, testAppPath, testItemPath, type NavigableAppFrontend } from "@/lib/apps-frontend";
import { getEnabledAppIds, getEnabledNavigableAppIds } from "@/lib/apps-utils";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { cn } from "@/lib/utils";
import {
CaretDownIcon,

View File

@ -44,7 +44,7 @@ import {
visualTreeToCel,
type RuleNode,
} from "@/lib/cel-visual-parser";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { hexclaveAppInternalsSymbol } from "@/lib/hexclave-app-internals";
import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';

View File

@ -8,7 +8,7 @@ import {
type DesignEditableGridItem,
} from "@/components/design-components";
import { ActionDialog, Checkbox, Switch, Typography } from "@/components/ui";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { GearSix, ShieldCheck, ShieldIcon, UserPlus, UsersIcon } from "@phosphor-icons/react";
import { typedFromEntries } from "@hexclave/shared/dist/utils/objects";
import { useCallback, useEffect, useMemo, useState } from "react";

View File

@ -3,7 +3,7 @@ import { useRouter } from "@/components/router";
import { Button, cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui";
import { ALL_APPS_FRONTEND, AppFrontend, getAppPath, getDocumentationHref } from "@/lib/apps-frontend";
import { isAppEnabled } from "@/lib/apps-utils";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { CheckIcon, DotsThreeVerticalIcon } from "@phosphor-icons/react";
import { ALL_APPS, AppId, getParentAppId } from "@hexclave/shared/dist/apps/apps-config";
import { appSquarePaddingExpression, appSquareWidthExpression, AppIcon as SharedAppIcon } from "@hexclave/shared/dist/apps/apps-ui";

View File

@ -7,7 +7,7 @@ import { useDebouncedAction } from "@/hooks/use-debounced-action";
import { createUnifiedAiTransport } from "@/components/assistant-ui/chat-stream";
import { buildDashboardMessages } from "@/lib/ai-dashboard/shared-prompt";
import type { AppId } from "@/lib/apps-frontend";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { useDashboardUser } from "@/lib/dashboard-user";
import { getPublicEnvVar } from "@/lib/env";
import { cn } from "@/lib/utils";

View File

@ -27,7 +27,7 @@ import { SimpleTooltip } from "@/components/ui/simple-tooltip";
import { Textarea } from "@/components/ui/textarea";
import { useDebouncedAction } from "@/hooks/use-debounced-action";
import { useFromNow } from "@/hooks/use-from-now";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import {
ArrowClockwiseIcon,
CheckCircleIcon,

View File

@ -7,7 +7,8 @@ import type { StackAdminApp } from "@hexclave/next";
import { captureError } from "@hexclave/shared/dist/utils/errors";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { ConfigAgentRunProgressContent, type AgentStage, getAdminInterface, isGithubPushedSourceWithAgentRun, useGithubRunActive, type GithubPushedSourceWithAgentRun } from "./config-update";
import { ConfigAgentRunProgressContent } from "./progress-content";
import { type AgentStage, getAdminInterface, isGithubPushedSourceWithAgentRun, useGithubRunActive, type GithubPushedSourceWithAgentRun } from "./shared";
/**
* Watches the linked-GitHub config source for an in-flight agent run and, when

View File

@ -0,0 +1,584 @@
'use client';
import { Link } from "@/components/link";
import { DesignButton, DesignDialog, DesignDialogClose } from "@/components/design-components";
import { useDashboardInternalUser } from "@/lib/dashboard-user";
import { ArrowsClockwise, GitBranch, GitCommit } from "@phosphor-icons/react";
import type { OAuthConnection, StackAdminApp } from "@hexclave/next";
import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
import { captureError } from "@hexclave/shared/dist/utils/errors";
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
import { GITHUB_SCOPE_REQUIREMENTS } from "@/lib/github-api";
import React, { Suspense, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
import { AgentDiffViewer, ConfigAgentRunProgressContent } from "./progress-content";
import { ConfigUpdateDialogContext, currentEpochMsFromPerformance, getAdminInterface, type AgentStage, type GithubPushedSource, isAgentStage, isGithubPushedSourceWithAgentRun } from "./shared";
type GithubPushDialogProps = {
open: boolean,
adminApp: StackAdminApp<false> | null,
source: GithubPushedSource,
configUpdate: EnvironmentConfigOverrideOverride | null,
projectId: string | undefined,
onSettle: (result: boolean) => void,
};
/**
* The new GitHub push dialog: shows a staged progress bar while the agent
* runs, then a diff review panel once the agent is done. The user must
* explicitly click "Commit" to push. No auto-commit.
*/
type ScopeCheck =
| { status: "no-account" }
| { status: "checking" }
| { status: "ok", account: OAuthConnection }
| { status: "missing-scopes" };
// "idle": waiting for user to start.
// "running": agent is in flight (non-dismissible; Cancel stops the sandbox).
// "cancelling": user clicked Cancel, waiting for terminal status.
// "awaiting_review": agent done, diff loaded, waiting for user to commit.
// "committing": user clicked Commit, pushing to GitHub.
type DialogPhase = "idle" | "running" | "cancelling" | "awaiting_review" | "committing";
function projectSettingsHref(projectId: string | undefined): string {
return `/projects/${projectId}/project-settings`;
}
/**
* Outer shell: renders the DesignDialog synchronously; the Suspense-suspending
* body (scope check) is isolated inside.
*/
export function GithubPushDialog({ open, adminApp, source, configUpdate, projectId, onSettle }: GithubPushDialogProps) {
const [scopeStatus, setScopeStatus] = useState<ScopeCheck["status"]>("checking");
const [phase, setPhase] = useState<DialogPhase>("idle");
const [stage, setStage] = useState<AgentStage | null>(null);
const [startedAt, setStartedAt] = useState<number>(0);
const [activity, setActivity] = useState<string | null>(null);
const [diff, setDiff] = useState<string | null>(null);
const [commitMessage, setCommitMessage] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Expose imperative handles from the body (which can suspend) to the outer shell.
const handlersRef = useRef<{
push: () => Promise<void>,
connect: () => Promise<void>,
cancel: () => Promise<void>,
commit: () => Promise<void>,
} | null>(null);
const dialogContext = useContext(ConfigUpdateDialogContext);
const isNonDismissible = phase === "running" || phase === "cancelling" || phase === "committing";
const description = (() => {
switch (phase) {
case "idle": {
switch (scopeStatus) {
case "no-account": { return "Connect a GitHub account to push configuration changes to this repository."; }
case "checking": { return "Checking GitHub permissions…"; }
case "ok": { return `This will apply your change to ${source.owner}/${source.repo}@${source.branch}.`; }
case "missing-scopes": { return `Your linked GitHub account is missing the "repo" and "workflow" permissions. Reconnect to grant them.`; }
}
break;
}
case "running":
case "cancelling": {
return `Applying your change in a sandbox — ${source.owner}/${source.repo}@${source.branch}`;
}
case "awaiting_review": {
return `Review the changes before committing to ${source.branch}.`;
}
case "committing": {
return `Pushing to ${source.owner}/${source.repo}@${source.branch}`;
}
}
})();
// Footer buttons
const footer = (() => {
if (phase === "running") {
return (
<div className="flex items-center gap-2">
<DesignButton
variant="outline"
size="sm"
onClick={async () => { await handlersRef.current?.cancel(); }}
>
Cancel
</DesignButton>
</div>
);
}
if (phase === "cancelling") {
return (
<DesignButton variant="outline" size="sm" disabled>
Cancelling
</DesignButton>
);
}
if (phase === "awaiting_review") {
return (
<div className="flex items-center gap-2 w-full">
<DesignButton
variant="outline"
size="sm"
onClick={async () => { await handlersRef.current?.cancel(); }}
>
Discard
</DesignButton>
<div className="flex-1" />
<div className="flex items-center gap-2">
<label htmlFor="push-commit-msg" className="text-xs text-muted-foreground whitespace-nowrap">
Commit message
</label>
<input
id="push-commit-msg"
type="text"
className="h-8 rounded-lg border border-border/50 bg-background px-3 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40 transition-colors duration-150 hover:transition-none w-52"
placeholder="chore(hexclave): update config from dashboard"
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
/>
<DesignButton
size="sm"
onClick={async () => { await handlersRef.current?.commit(); }}
>
<GitCommit className="h-3.5 w-3.5 mr-1.5" />
Commit
</DesignButton>
</div>
</div>
);
}
if (phase === "committing") {
return (
<div className="flex items-center gap-2">
<DesignButton size="sm" disabled loading>
Committing
</DesignButton>
</div>
);
}
// idle
return (
<div className="flex items-center gap-2">
<DesignDialogClose asChild>
<DesignButton variant="outline" size="sm" onClick={async () => { onSettle(false); }}>
Cancel
</DesignButton>
</DesignDialogClose>
{scopeStatus === "no-account" || scopeStatus === "missing-scopes" ? (
<DesignButton size="sm" onClick={async () => { await handlersRef.current?.connect(); }}>
{scopeStatus === "no-account" ? "Connect with GitHub" : "Reconnect with GitHub"}
</DesignButton>
) : (
<DesignButton
size="sm"
onClick={async () => { await handlersRef.current?.push(); }}
disabled={scopeStatus === "checking"}
loading={scopeStatus === "checking"}
>
<ArrowsClockwise className="h-3.5 w-3.5 mr-1.5" />
Start update
</DesignButton>
)}
</div>
);
})();
// Dialog size grows when showing the diff
const dialogSize = phase === "awaiting_review" ? "3xl" : "lg";
return (
<DesignDialog
open={open}
onOpenChange={(o) => {
if (o || isNonDismissible) return;
onSettle(false);
}}
size={dialogSize}
icon={GitBranch}
title="Push configuration to GitHub"
description={description}
hideTopCloseButton={isNonDismissible}
footer={footer}
contentProps={{ onPointerDownOutside: isNonDismissible ? (e) => e.preventDefault() : undefined, onEscapeKeyDown: isNonDismissible ? (e) => e.preventDefault() : undefined }}
>
<Suspense fallback={<div className="py-2 text-sm text-muted-foreground">Loading</div>}>
<GithubPushBody
adminApp={adminApp}
source={source}
configUpdate={configUpdate}
projectId={projectId}
onSettle={onSettle}
phase={phase}
stage={stage}
startedAt={startedAt}
activity={activity}
diff={diff}
commitMessage={commitMessage}
errorMessage={errorMessage}
onScopeStatusChange={setScopeStatus}
onPhaseChange={setPhase}
onStageChange={setStage}
onStartedAtChange={setStartedAt}
onActivityChange={setActivity}
onDiffChange={setDiff}
onErrorChange={setErrorMessage}
handlersRef={handlersRef}
dialogContext={dialogContext}
/>
</Suspense>
</DesignDialog>
);
}
type GithubPushBodyProps = {
adminApp: StackAdminApp<false> | null,
source: GithubPushedSource,
configUpdate: EnvironmentConfigOverrideOverride | null,
projectId: string | undefined,
onSettle: (result: boolean) => void,
phase: DialogPhase,
stage: AgentStage | null,
startedAt: number,
activity: string | null,
diff: string | null,
commitMessage: string,
errorMessage: string | null,
onScopeStatusChange: (s: ScopeCheck["status"]) => void,
onPhaseChange: (p: DialogPhase) => void,
onStageChange: (s: AgentStage | null) => void,
onStartedAtChange: (ms: number) => void,
onActivityChange: (a: string | null) => void,
onDiffChange: (d: string | null) => void,
onErrorChange: (e: string | null) => void,
handlersRef: React.MutableRefObject<{
push: () => Promise<void>,
connect: () => Promise<void>,
cancel: () => Promise<void>,
commit: () => Promise<void>,
} | null>,
dialogContext: { setGithubRunActive: (v: boolean) => void } | null,
};
function GithubPushBody({
adminApp,
source,
configUpdate,
projectId,
onSettle,
phase,
stage,
startedAt,
activity,
diff,
commitMessage,
errorMessage,
onScopeStatusChange,
onPhaseChange,
onStageChange,
onStartedAtChange,
onActivityChange,
onDiffChange,
onErrorChange,
handlersRef,
dialogContext,
}: GithubPushBodyProps) {
const user = useDashboardInternalUser();
const githubAccounts = user.useConnectedAccounts().filter((account) => account.provider === "github");
const githubAccountsKey = githubAccounts.map((a) => a.providerAccountId).join("|");
const [scopeCheck, setScopeCheck] = useState<ScopeCheck>(
githubAccounts.length === 0 ? { status: "no-account" } : { status: "checking" },
);
const placeholderCommitMessage = "chore(hexclave): update config from dashboard";
useLayoutEffect(() => {
onScopeStatusChange(scopeCheck.status);
}, [scopeCheck.status, onScopeStatusChange]);
useEffect(() => {
if (githubAccounts.length === 0) {
setScopeCheck({ status: "no-account" });
return;
}
const cancelToken = { cancelled: false };
setScopeCheck({ status: "checking" });
runAsynchronously(async () => {
for (const account of githubAccounts) {
let tokenResult;
try {
tokenResult = await account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS });
} catch {
continue;
}
if (cancelToken.cancelled) return;
if (tokenResult.status === "ok") {
setScopeCheck({ status: "ok", account });
return;
}
}
if (!cancelToken.cancelled) setScopeCheck({ status: "missing-scopes" });
});
return () => {
cancelToken.cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- githubAccountsKey
}, [githubAccountsKey]);
const handlePush = useCallback(async () => {
if (configUpdate == null) {
onErrorChange("No configuration changes to push.");
return;
}
if (scopeCheck.status !== "ok") {
onErrorChange("Connect a GitHub account with the required scopes before pushing changes.");
return;
}
const adminInterface = getAdminInterface(adminApp);
if (adminInterface == null || typeof adminInterface.applyConfigViaAgent !== "function") {
onErrorChange("This dashboard build can't push config to GitHub. Please refresh and try again.");
return;
}
onErrorChange(null);
try {
const tokenResult = await scopeCheck.account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS });
if (tokenResult.status !== "ok") {
onErrorChange("Could not get a GitHub token with the required permissions. Reconnect your GitHub account and try again.");
return;
}
const start = await adminInterface.applyConfigViaAgent({
configUpdate,
// Pass a placeholder; the real commit message is gathered at review time.
commitMessage: placeholderCommitMessage,
githubAccessToken: tokenResult.data.accessToken,
});
if (start.status === "already-running") {
onErrorChange("Another configuration update is already running for this project. Wait for it to finish, then try again.");
return;
}
const runStartedAtWallMs = currentEpochMsFromPerformance();
const runStartedAtMonotonicMs = performance.now();
onStartedAtChange(runStartedAtWallMs);
dialogContext?.setGithubRunActive(true);
onPhaseChange("running");
onActivityChange(null);
onStageChange("initializing_sandbox");
// Poll until the run transitions out of "running" (either to
// "awaiting_review", a terminal status, or times out).
const deadline = performance.now() + 8 * 60_000;
while (performance.now() < deadline) {
await new Promise((r) => setTimeout(r, 3000));
let latest: unknown;
try {
latest = await adminInterface.getPushedConfigSource();
} catch {
continue;
}
const run = isGithubPushedSourceWithAgentRun(latest) ? latest.agent_run : null;
// Ignore stale runs from before this one started.
if (run == null || (typeof run.started_at === "number" && run.started_at < runStartedAtWallMs - 5000)) continue;
if (run.status === "running") {
if (typeof run.progress === "string") onActivityChange(run.progress);
if (isAgentStage(run.stage)) onStageChange(run.stage);
continue;
}
// Non-running status: transition.
dialogContext?.setGithubRunActive(false);
if (run.status === "awaiting_review") {
onPhaseChange("awaiting_review");
onStageChange("awaiting_review");
if (typeof run.diff === "string") onDiffChange(run.diff);
return;
}
if (run.status === "error") {
onPhaseChange("idle");
onStageChange(null);
onErrorChange("The config agent failed to apply your change.");
return;
}
if (run.status === "cancelled") {
onPhaseChange("idle");
onStageChange(null);
onSettle(false);
return;
}
if (run.status === "no-change") {
onPhaseChange("idle");
onStageChange(null);
onErrorChange("The config agent finished without producing a diff. No commit was created; try the update again.");
return;
}
// success is only expected from older auto-commit flows or a race with
// a completed commit. Settle so the dashboard can refresh its local state.
onPhaseChange("idle");
onStageChange(null);
onSettle(true);
return;
}
dialogContext?.setGithubRunActive(false);
onPhaseChange("idle");
onStageChange(null);
const elapsedSeconds = Math.floor((performance.now() - runStartedAtMonotonicMs) / 1000);
onErrorChange(`Timed out after ${elapsedSeconds}s waiting for the config agent. Your change may still be in progress; check the linked repository.`);
} catch (error) {
captureError("config-update-github-agent", {
projectId,
owner: source.owner,
repo: source.repo,
branch: source.branch,
configFilePath: source.configFilePath,
cause: error,
});
dialogContext?.setGithubRunActive(false);
onPhaseChange("idle");
onStageChange(null);
onErrorChange("Unknown error pushing to GitHub.");
}
}, [adminApp, configUpdate, dialogContext, onActivityChange, onDiffChange, onErrorChange, onPhaseChange, onSettle, onStageChange, onStartedAtChange, projectId, scopeCheck, source]);
const handleCancel = useCallback(async () => {
const adminInterface = getAdminInterface(adminApp);
if (adminInterface == null || typeof adminInterface.cancelConfigAgentRun !== "function") {
onErrorChange("This dashboard build can't cancel a config run. Please refresh and try again.");
return;
}
onPhaseChange("cancelling");
try {
await adminInterface.cancelConfigAgentRun();
} catch (error) {
captureError("config-update-github-cancel", error);
}
// The poll loop in handlePush will observe the terminal `cancelled` status and settle.
}, [adminApp, onErrorChange, onPhaseChange]);
const handleCommit = useCallback(async () => {
if (scopeCheck.status !== "ok") {
onErrorChange("GitHub account not connected. Please reconnect and try again.");
return;
}
const adminInterface = getAdminInterface(adminApp);
if (adminInterface == null || typeof adminInterface.commitConfigAgentRun !== "function") {
onErrorChange("This dashboard build can't commit. Please refresh and try again.");
return;
}
onPhaseChange("committing");
onErrorChange(null);
try {
const tokenResult = await scopeCheck.account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS });
if (tokenResult.status !== "ok") {
onPhaseChange("awaiting_review");
onErrorChange("Could not get a GitHub token. Reconnect your GitHub account and try again.");
return;
}
const result = await adminInterface.commitConfigAgentRun({
githubAccessToken: tokenResult.data.accessToken,
commitMessage: commitMessage.trim().length > 0 ? commitMessage : undefined,
});
if (result.status === "sandbox-expired") {
onPhaseChange("idle");
onErrorChange("The sandbox session expired. Please retry the update.");
return;
}
if (result.status === "not-awaiting-review") {
onPhaseChange("idle");
onErrorChange("There is no config diff waiting to commit. Start the update again.");
return;
}
// "committing" — poll until done
const adminInterface2 = adminInterface;
const deadline = performance.now() + 2 * 60_000;
while (performance.now() < deadline) {
await new Promise((r) => setTimeout(r, 3000));
let latest: unknown;
try {
latest = await adminInterface2.getPushedConfigSource();
} catch {
continue;
}
const run = isGithubPushedSourceWithAgentRun(latest) ? latest.agent_run : null;
if (run == null || run.status === "awaiting_review") continue;
if (run.status === "success") {
onPhaseChange("idle");
onSettle(true);
return;
}
if (run.status === "error") {
onPhaseChange("awaiting_review");
onErrorChange("Failed to commit and push the changes. Please try again.");
return;
}
if (run.status === "cancelled") {
onPhaseChange("idle");
onSettle(false);
return;
}
}
onPhaseChange("awaiting_review");
onErrorChange("Timed out waiting for the commit. Check the repository for status.");
} catch (error) {
captureError("config-update-github-commit", error);
onPhaseChange("awaiting_review");
onErrorChange("Unknown error committing to GitHub.");
}
}, [adminApp, commitMessage, onErrorChange, onPhaseChange, onSettle, scopeCheck]);
const handleConnect = useCallback(async () => {
try {
await user.getOrLinkConnectedAccount("github", { scopes: GITHUB_SCOPE_REQUIREMENTS });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error connecting to GitHub.";
onErrorChange(message);
}
}, [onErrorChange, user]);
useEffect(() => {
handlersRef.current = { push: handlePush, connect: handleConnect, cancel: handleCancel, commit: handleCommit };
}, [handlersRef, handlePush, handleConnect, handleCancel, handleCommit]);
return (
<div className="space-y-4">
{/* Stage progress bar — shown while running */}
{(phase === "running" || phase === "cancelling") && (
<ConfigAgentRunProgressContent
isCancelling={phase === "cancelling"}
stage={stage}
startedAt={startedAt}
activity={activity}
errorMessage={errorMessage}
/>
)}
{/* Diff viewer — shown when awaiting review */}
{phase === "awaiting_review" && diff != null && diff.trim().length > 0 && (
<AgentDiffViewer diff={diff} />
)}
{/* Error */}
{phase !== "running" && phase !== "cancelling" && errorMessage != null && (
<p className="rounded-lg bg-destructive/8 px-3 py-2 text-sm text-destructive">{errorMessage}</p>
)}
{/* Unlink hint — shown in idle state */}
{phase === "idle" && (
<p className="text-xs text-muted-foreground">
If your configuration is no longer on GitHub, you can{" "}
<Link href={projectSettingsHref(projectId)} className="underline">
unlink it in Project Settings
</Link>.
</p>
)}
</div>
);
}

View File

@ -0,0 +1,296 @@
'use client';
import { ActionDialog, Button } from "@/components/ui";
import { getPublicEnvVar } from "@/lib/env";
import type { PushedConfigSource, StackAdminApp } from "@hexclave/next";
import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
import { HexclaveAssertionError, captureError } from "@hexclave/shared/dist/utils/errors";
import React, { useCallback, useContext, useState } from "react";
import { GithubPushDialog } from "./github-push-dialog";
import { updateRemoteDevelopmentEnvironmentConfigFile } from "./remote-development-environment";
import { ConfigUpdateDialogContext, getAdminInterface, isGithubPushedSourceWithAgentRun } from "./shared";
type ConfigUpdateDialogState = {
isOpen: boolean,
adminApp: StackAdminApp<false> | null,
configUpdate: EnvironmentConfigOverrideOverride | null,
resolve: ((result: boolean) => void) | null,
source: PushedConfigSource | null,
};
export function ConfigUpdateDialogProvider({ children }: { children: React.ReactNode }) {
const [dialogState, setDialogState] = useState<ConfigUpdateDialogState>({
isOpen: false,
adminApp: null,
configUpdate: null,
resolve: null,
source: null,
});
const [githubRunActive, setGithubRunActive] = useState(false);
const showPushableDialog = useCallback(async (adminApp: StackAdminApp<false>, configUpdate: EnvironmentConfigOverrideOverride): Promise<boolean> => {
const project = await adminApp.getProject();
const source = await project.getPushedConfigSource();
// The public `PushedConfigSource` type drops `agent_run`; read the raw
// interface source so the page-load watcher can own already-running jobs.
if (source.type === "pushed-from-github") {
const iface = getAdminInterface(adminApp);
if (iface != null && typeof iface.getPushedConfigSource === "function") {
let rawSource: unknown = null;
try {
rawSource = await iface.getPushedConfigSource();
} catch (error) {
captureError("config-update-source-agent-run-check", error);
}
if (isGithubPushedSourceWithAgentRun(rawSource) && rawSource.agent_run?.status === "running") {
return false;
}
}
}
let shouldUpdate = true;
if (source.type !== "unlinked") {
shouldUpdate = await new Promise((resolve) => {
setDialogState({
isOpen: true,
adminApp,
configUpdate,
resolve,
source,
});
});
}
if (shouldUpdate) {
await project.updatePushedConfig(configUpdate);
if (!project.isDevelopmentEnvironment) {
await project.resetConfigOverrideKeys("environment", Object.keys(configUpdate));
}
return true;
}
return false;
}, []);
const settleDialog = useCallback((result: boolean) => {
const resolve = dialogState.resolve;
setDialogState({
isOpen: false,
adminApp: null,
configUpdate: null,
resolve: null,
source: null,
});
resolve?.(result);
}, [dialogState.resolve]);
const projectId = dialogState.adminApp?.projectId;
const renderDialog = () => {
if (!dialogState.isOpen || !dialogState.source) {
return null;
}
switch (dialogState.source.type) {
case "pushed-from-github": {
return (
<GithubPushDialog
open={dialogState.isOpen}
adminApp={dialogState.adminApp}
source={dialogState.source}
configUpdate={dialogState.configUpdate}
projectId={projectId}
onSettle={settleDialog}
/>
);
}
case "pushed-from-unknown": {
return (
<ActionDialog
open={dialogState.isOpen}
onClose={() => settleDialog(false)}
title="Configuration Managed by CLI"
description="This project's configuration was pushed via the Hexclave CLI."
okButton={{
label: "Go to Project Settings",
onClick: async () => {
window.location.href = `/projects/${projectId}/project-settings`;
},
}}
cancelButton={{
label: "Cancel",
onClick: async () => {
settleDialog(false);
},
}}
>
<div className="text-sm text-muted-foreground space-y-2">
<p>
To make changes, you can either:
</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Push updates through the Hexclave CLI</li>
<li>Unlink the CLI in Project Settings to edit directly on this dashboard</li>
</ul>
</div>
</ActionDialog>
);
}
default: {
return null;
}
}
};
return (
<ConfigUpdateDialogContext.Provider value={{ showPushableDialog, githubRunActive, setGithubRunActive }}>
{children}
{renderDialog()}
</ConfigUpdateDialogContext.Provider>
);
}
function useConfigUpdateDialog() {
const context = useContext(ConfigUpdateDialogContext);
if (context == null) {
throw new Error("useConfigUpdateDialog must be used within a ConfigUpdateDialogProvider");
}
return context;
}
export type UpdateConfigOptions = {
adminApp: StackAdminApp<false>,
configUpdate: EnvironmentConfigOverrideOverride,
pushable: boolean,
};
export function useUpdateConfig() {
const { showPushableDialog } = useConfigUpdateDialog();
return useCallback(async (options: UpdateConfigOptions): Promise<boolean> => {
const { adminApp, configUpdate, pushable } = options;
if (getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true") {
if (!pushable) {
throw new HexclaveAssertionError("These settings are read-only in a development environment. Update them in your production deployment instead.");
}
if (await updateRemoteDevelopmentEnvironmentConfigFile(adminApp, configUpdate) === "redirecting") {
return false;
}
return true;
}
if (pushable) {
return await showPushableDialog(adminApp, configUpdate);
}
const project = await adminApp.getProject();
if (project.isDevelopmentEnvironment) {
alert("These settings are read-only in a development environment. Update them in your production deployment instead.");
return false;
}
// eslint-disable-next-line no-restricted-syntax -- this is the hook implementation itself
await project.updateConfig(configUpdate);
return true;
}, [showPushableDialog]);
}
export type ConfigUpdateButtonProps = {
adminApp: StackAdminApp<false>,
configUpdate: () => Promise<EnvironmentConfigOverrideOverride>,
pushable: boolean,
onUpdated?: () => void | Promise<void>,
actionType: "save" | "create",
disabled?: boolean,
className?: string,
variant?: "default" | "secondary" | "outline" | "ghost" | "destructive" | "link",
size?: "default" | "sm" | "lg" | "icon",
};
export function ConfigUpdateButton({
adminApp,
configUpdate,
pushable,
onUpdated,
actionType,
disabled,
className,
variant = "default",
size = "default",
}: ConfigUpdateButtonProps) {
const updateConfig = useUpdateConfig();
const handleClick = async () => {
const configUpdateValue = await configUpdate();
const success = await updateConfig({
adminApp,
configUpdate: configUpdateValue,
pushable,
});
if (success) {
await onUpdated?.();
}
};
const label = actionType === "save" ? "Save changes" : "Create";
return (
<Button
onClick={handleClick}
disabled={disabled}
className={className}
variant={variant}
size={size}
>
{label}
</Button>
);
}
export type UnsavedChangesFooterProps = {
hasChanges: boolean,
adminApp: StackAdminApp<false>,
configUpdate: () => Promise<EnvironmentConfigOverrideOverride>,
pushable: boolean,
onDiscard: () => void,
onSaved?: () => void | Promise<void>,
actionType?: "save" | "create",
};
export function UnsavedChangesFooter({
hasChanges,
adminApp,
configUpdate,
pushable,
onDiscard,
onSaved,
actionType = "save",
}: UnsavedChangesFooterProps) {
if (!hasChanges) {
return null;
}
return (
<div className="flex items-center justify-end gap-2 pt-4 border-t border-border/40">
<Button
variant="ghost"
size="sm"
onClick={onDiscard}
>
Discard changes
</Button>
<ConfigUpdateButton
adminApp={adminApp}
configUpdate={configUpdate}
pushable={pushable}
onUpdated={onSaved}
actionType={actionType}
size="sm"
/>
</div>
);
}

View File

@ -0,0 +1,187 @@
'use client';
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
import React, { useEffect, useState } from "react";
import type { FileDiffProps } from "@pierre/diffs/react";
import type { FileDiffMetadata } from "@pierre/diffs";
import { currentEpochMsFromPerformance, type AgentStage } from "./shared";
type StepDef = { key: AgentStage, label: string, subLabel?: string };
const STAGE_STEPS: StepDef[] = [
{ key: "initializing_sandbox", label: "Initializing sandbox" },
{ key: "cloning_repo", label: "Cloning repo" },
{ key: "agent_making_changes", label: "Agent making changes", subLabel: "Editing config file" },
{ key: "awaiting_review", label: "Ready to review" },
];
function stageIndex(stage: AgentStage | null | undefined): number {
if (stage == null) return -1;
return STAGE_STEPS.findIndex((s) => s.key === stage);
}
/**
* A compact stage tracker shown while the agent is running. Each step shows
* elapsed seconds and a live activity sub-label for the active step.
*/
export function AgentStageProgress({
stage,
startedAt,
activity,
}: {
stage: AgentStage | null | undefined,
/** Unix ms timestamp of when the run started (from agent_run.started_at). */
startedAt: number,
activity?: string | null,
}) {
const [elapsedMs, setElapsedMs] = useState(0);
useEffect(() => {
const performanceStartedAt = performance.now();
const t = setInterval(() => setElapsedMs(performance.now() - performanceStartedAt), 1000);
return () => clearInterval(t);
}, []);
const activeIdx = stageIndex(stage);
// Server-side run timestamps are wall-clock epoch values. Once mounted, keep
// the visible elapsed counter on a monotonic clock so local clock jumps don't
// make the progress UI move backwards.
const initialElapsedMs = Math.max(0, currentEpochMsFromPerformance() - startedAt);
const overallElapsed = Math.max(0, Math.floor((initialElapsedMs + elapsedMs) / 1000));
return (
<div className="space-y-2">
{STAGE_STEPS.map((step, idx) => {
const isDone = idx < activeIdx;
const isActive = idx === activeIdx;
const isPending = idx > activeIdx;
return (
<div key={step.key} className="flex items-start gap-3">
<div className={`mt-0.5 h-5 w-5 rounded-full flex items-center justify-center shrink-0 transition-colors duration-150 ${
isDone
? "bg-primary/15 text-primary"
: isActive
? "bg-primary text-primary-foreground"
: "bg-foreground/[0.06] text-muted-foreground"
}`}>
{isDone ? (
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
) : (
<span className={`text-[9px] font-semibold leading-none ${isPending ? "opacity-40" : ""}`}>{idx + 1}</span>
)}
</div>
<div className="flex-1 min-w-0 pt-0.5">
<div className={`text-xs font-medium leading-snug flex items-center gap-2 ${
isPending ? "text-muted-foreground opacity-50" : "text-foreground"
}`}>
<span>{step.label}</span>
{isActive && (
<span className="text-muted-foreground font-normal tabular-nums">
{overallElapsed}s
</span>
)}
</div>
{isActive && activity != null && activity.trim().length > 0 && (
<div className="mt-1 font-mono text-[11px] text-muted-foreground leading-relaxed truncate">
<span className="text-primary mr-1.5"></span>
{activity.split("\n").filter((l) => l.trim()).at(-1)}
</div>
)}
</div>
</div>
);
})}
</div>
);
}
export function ConfigAgentRunProgressContent({
isCancelling,
stage,
startedAt,
activity,
errorMessage,
}: {
isCancelling: boolean,
stage: AgentStage | null | undefined,
startedAt: number,
activity?: string | null,
errorMessage?: string | null,
}) {
return (
<div className="space-y-3">
{isCancelling ? (
<p className="text-sm text-muted-foreground">Cancelling the update and stopping the agent</p>
) : (
<AgentStageProgress stage={stage} startedAt={startedAt} activity={activity} />
)}
{errorMessage != null && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</div>
);
}
/**
* Lazy-loaded diff viewer. We parse the sandbox's full `git diff` into file
* diffs, then render each file with Pierre's React renderer. `PatchDiff` only
* accepts a single-file patch, while the config agent may legitimately edit
* helpers/imported config files too.
*/
export function AgentDiffViewer({ diff }: { diff: string }) {
const [renderer, setRenderer] = useState<{
FileDiff: React.ComponentType<FileDiffProps<undefined>>,
files: FileDiffMetadata[],
} | null>(null);
useEffect(() => {
const cancelToken = { cancelled: false };
runAsynchronously(async () => {
try {
const [{ parsePatchFiles }, reactMod] = await Promise.all([
import("@pierre/diffs"),
import("@pierre/diffs/react"),
]);
if (cancelToken.cancelled) return;
const files = parsePatchFiles(diff, "config-agent-review", true).flatMap((patch) => patch.files);
if (files.length === 0) return;
setRenderer({ FileDiff: reactMod.FileDiff, files });
} catch {
// Module failed to load — fall back to raw diff text.
}
});
return () => {
cancelToken.cancelled = true;
};
}, [diff]);
if (renderer != null) {
const { FileDiff } = renderer;
return (
<div className="max-h-[60vh] space-y-3 overflow-auto rounded-xl border border-border/30 bg-background/60 p-2">
{renderer.files.map((fileDiff, index) => (
<FileDiff
key={fileDiff.cacheKey ?? `${fileDiff.name}-${index}`}
fileDiff={fileDiff}
options={{
theme: { dark: "github-dark", light: "github-light" },
diffStyle: "unified",
hunkSeparators: "line-info-basic",
overflow: "scroll",
}}
/>
))}
</div>
);
}
return (
<pre className="max-h-96 overflow-auto rounded-xl border border-border/30 bg-muted/20 p-4 font-mono text-[11px] text-foreground leading-relaxed whitespace-pre">
{diff}
</pre>
);
}

View File

@ -0,0 +1,33 @@
import { fetchWithRemoteDevelopmentEnvironmentBrowserSecret, RemoteDevelopmentEnvironmentBrowserSecretRedirectingError } from "@/app/remote-development-environment-browser-secret-client";
import type { StackAdminApp } from "@hexclave/next";
import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
export async function updateRemoteDevelopmentEnvironmentConfigFile(
adminApp: StackAdminApp<false>,
configUpdate: EnvironmentConfigOverrideOverride,
): Promise<"updated" | "redirecting"> {
try {
const response = await fetchWithRemoteDevelopmentEnvironmentBrowserSecret("/api/remote-development-environment/config/apply-update", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
project_id: adminApp.projectId,
config_update: configUpdate,
wait_for_sync: true,
}),
signal: AbortSignal.timeout(130_000),
});
if (!response.ok) {
throw new Error(`Failed to update local development environment config (${response.status}): ${await response.text()}`);
}
return "updated";
} catch (error) {
if (error instanceof RemoteDevelopmentEnvironmentBrowserSecretRedirectingError) {
return "redirecting";
}
throw error;
}
}

View File

@ -0,0 +1,74 @@
'use client';
import type { PushedConfigSource, StackAdminApp } from "@hexclave/next";
import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
import type { HexclaveAdminInterface } from "@hexclave/shared/dist/interface/admin-interface";
import { createContext, useContext } from "react";
export type ConfigAgentRunStatus = "running" | "awaiting_review" | "success" | "no-change" | "error" | "cancelled";
export type AgentStage = "initializing_sandbox" | "cloning_repo" | "agent_making_changes" | "awaiting_review";
export type GithubPushedSource = Extract<PushedConfigSource, { type: "pushed-from-github" }>;
export type GithubPushedSourceWithAgentRun = GithubPushedSource & {
agent_run?: {
status: ConfigAgentRunStatus,
started_at: number,
finished_at?: number,
progress?: string,
sandbox_id?: string,
commit_url?: string,
new_commit_hash?: string,
error?: string,
stage?: AgentStage,
diff?: string,
},
};
export function isAgentStage(value: unknown): value is AgentStage {
return value === "initializing_sandbox" || value === "cloning_repo" || value === "agent_making_changes" || value === "awaiting_review";
}
export function isGithubPushedSourceWithAgentRun(source: unknown): source is GithubPushedSourceWithAgentRun {
return typeof source === "object" && source != null && "type" in source && source.type === "pushed-from-github";
}
export function currentEpochMsFromPerformance(): number {
return performance.timeOrigin + performance.now();
}
/**
* Reaches the admin app's underlying `HexclaveAdminInterface`, which carries the
* config-agent endpoints (`applyConfigViaAgent`, `cancelConfigAgentRun`,
* `getPushedConfigSource`) we call directly rather than via generated app
* methods to keep this feature self-contained. `_interface` is a protected
* member, so we read it reflectively (the same pattern the SDK's own cross-domain
* tests use). Returns `null` if the app doesn't expose one.
*
* NOTE: these methods exist on the type, but the installed `@hexclave/next` build
* could predate them, so callers still runtime-check the specific method before
* use and degrade gracefully ("refresh and try again").
*/
export function getAdminInterface(adminApp: StackAdminApp<false> | null | undefined): HexclaveAdminInterface | null {
if (adminApp == null) return null;
// `Reflect.get` returns `any`; the typed annotation documents the contract
// without an explicit cast (and without an `instanceof`, which is unreliable
// across package-boundary copies of the class).
const iface: HexclaveAdminInterface | undefined = Reflect.get(adminApp, "_interface");
return iface ?? null;
}
export const ConfigUpdateDialogContext = createContext<{
showPushableDialog: (adminApp: StackAdminApp<false>, configUpdate: EnvironmentConfigOverrideOverride) => Promise<boolean>,
// True while THIS tab's push dialog is actively managing a started run, so the
// page-load watcher (ConfigAgentRunWatcher) doesn't also pop its own modal for
// the same run. The watcher owns the modal only for runs this tab didn't start
// (other tabs / reloads).
githubRunActive: boolean,
setGithubRunActive: (active: boolean) => void,
} | null>(null);
/** Read-only accessor for the watcher (mounted below this provider). */
export function useGithubRunActive(): boolean {
return useContext(ConfigUpdateDialogContext)?.githubRunActive ?? false;
}

View File

@ -2,7 +2,7 @@
import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import { ProductDialog } from "@/components/payments/product-dialog";
import { ActionCell, ActionDialog, toast } from "@/components/ui";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { branchPaymentsSchema } from "@hexclave/shared/dist/config/schema";
import { typedEntries, typedFromEntries } from "@hexclave/shared/dist/utils/objects";
import {

View File

@ -3,7 +3,7 @@
import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import { SettingSwitch } from "@/components/settings";
import { ActionDialog, Typography } from "@/components/ui";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { EnvelopeSimpleIcon } from "@phosphor-icons/react";
import type { RestrictedReason } from "@hexclave/shared/dist/schema-fields";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";

View File

@ -6,7 +6,7 @@ import { CheckboxField, InputField, SelectField } from "@/components/form-fields
import { IncludedItemEditorField } from "@/components/payments/included-item-editor";
import { PriceEditorField } from "@/components/payments/price-editor";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, SimpleTooltip, toast } from "@/components/ui";
import { useUpdateConfig } from "@/lib/config-update";
import { useUpdateConfig } from "@/components/config-update";
import { AdminProject } from "@hexclave/next";
import { pricesSchema, productSchema, userSpecifiedIdSchema, yupRecord } from "@hexclave/shared/dist/schema-fields";
import { has } from "@hexclave/shared/dist/utils/objects";

File diff suppressed because it is too large Load Diff

View File

@ -118,3 +118,14 @@ import.meta.vitest?.test("evalConfigFileContent rejects content without config e
import.meta.vitest?.test("evalConfigFileContent rejects arbitrary string config values", ({ expect }) => {
expect(() => evalConfigFileContent('export const config = "arbitrary-string";', "stack.config.ts")).toThrow(/must be "show-onboarding"/);
});
import.meta.vitest?.test("evalConfigFileContent rejects unresolvable config factories", ({ expect }) => {
expect(() => evalConfigFileContent("export const config = makeConfig();", "stack.config.ts")).toThrow();
});
import.meta.vitest?.test("evalConfigFileContent rejects missing config import targets", ({ expect }) => {
expect(() => evalConfigFileContent(`
import missingConfigPart from "./missing-config-part";
export const config = { auth: missingConfigPart };
`, "/tmp/hexclave-missing-import-config.ts")).toThrow();
});

View File

@ -884,6 +884,14 @@ export const oauthProviderAllowConnectedAccountsSchema = yupBoolean().meta({ ope
export const oauthProviderAccountIdSchema = yupString().meta({ openapiField: { description: 'Account ID of the OAuth provider. This uniquely identifies the account on the provider side.', exampleValue: 'google-account-id-12345' } });
export const oauthProviderProviderConfigIdSchema = yupString().meta({ openapiField: { description: 'Provider config ID of the OAuth provider. This uniquely identifies the provider config on config.json file', exampleValue: 'google' } });
export const configAgentSafeErrorMessages = [
"The config agent failed to apply the change.",
"Sandbox session expired. Please retry the update.",
"Failed to commit and push the config changes.",
] as const;
export type ConfigAgentSafeErrorMessage = typeof configAgentSafeErrorMessages[number];
export const configAgentSafeErrorMessageSchema = yupString().oneOf(configAgentSafeErrorMessages);
// Headers
export const basicAuthorizationHeaderSchema = yupString().test('is-basic-authorization-header', 'Authorization header must be in the format "Basic <base64>"', (value) => {
if (!value) return true;
@ -948,7 +956,7 @@ export const branchConfigSourceSchema = yupUnion(
started_at: yupNumber().defined(),
finished_at: yupNumber().optional(),
commit_url: urlSchema.optional(),
error: yupString().optional(),
error: configAgentSafeErrorMessageSchema.optional(),
// Vercel Sandbox id of the in-flight run, recorded while `status === "running"` or `"awaiting_review"`
// so a cancel request (a different invocation) can hard-stop the sandbox.
// Cleared/ignored once the run reaches a terminal status.
@ -972,3 +980,32 @@ export const branchConfigSourceSchema = yupUnion(
type: yupString().oneOf(["unlinked"]).defined(),
}),
);
import.meta.vitest?.test("branchConfigSourceSchema only allows safe config-agent error messages", async ({ expect }) => {
const source = {
type: "pushed-from-github",
owner: "hexclave",
repo: "hexclave",
branch: "dev",
commit_hash: "abc123",
config_file_path: "hexclave.config.ts",
};
expect(await branchConfigSourceSchema.isValid({
...source,
agent_run: {
status: "error",
started_at: 1,
error: "The config agent failed to apply the change.",
},
})).toMatchInlineSnapshot(`true`);
expect(await branchConfigSourceSchema.isValid({
...source,
agent_run: {
status: "error",
started_at: 1,
error: "ENOENT: tokenized internal failure",
},
})).toMatchInlineSnapshot(`false`);
});