mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
chore: address config agent review cleanup
This commit is contained in:
parent
2558a63a81
commit
49a0c1083f
@ -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";
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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.");
|
||||
@ -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 }[]>`
|
||||
|
||||
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -86,7 +86,7 @@ vi.mock("@/lib/env", () => ({
|
||||
getPublicEnvVar: () => "false",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/config-update", () => ({
|
||||
vi.mock("@/components/config-update", () => ({
|
||||
useUpdateConfig: () => mockUpdateConfig,
|
||||
}));
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
296
apps/dashboard/src/components/config-update/index.tsx
Normal file
296
apps/dashboard/src/components/config-update/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
apps/dashboard/src/components/config-update/progress-content.tsx
Normal file
187
apps/dashboard/src/components/config-update/progress-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
74
apps/dashboard/src/components/config-update/shared.tsx
Normal file
74
apps/dashboard/src/components/config-update/shared.tsx
Normal 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;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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
@ -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();
|
||||
});
|
||||
|
||||
@ -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`);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user