From 2558a63a8133ece00b04ac2a837cc21ef353697f Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 25 Jun 2026 17:12:42 -0700 Subject: [PATCH] feat: implement two-phase review flow for config updates - Introduced a new API route for committing changes after user review, allowing the agent to keep the sandbox alive for inspection before finalizing updates. - Enhanced the existing applyConfigUpdate function to transition to an awaiting review state, storing the diff for user visibility. - Added progress tracking and stage reporting for the config agent run, improving user feedback during the update process. - Updated the dashboard to reflect the new review stages and provide a more interactive experience for managing configuration changes. Co-Authored-By: mantra --- .../scripts/spike-orchestrator-e2e.mts | 26 +- .../internal/config/github/apply/route.tsx | 37 +- .../internal/config/github/commit/route.tsx | 99 ++ apps/backend/src/lib/config/index.tsx | 88 +- apps/backend/src/lib/config/repo-agent.tsx | 175 +++- apps/backend/src/lib/local-emulator.ts | 16 +- apps/dashboard/package.json | 1 + apps/dashboard/src/lib/config-agent-run.tsx | 65 +- apps/dashboard/src/lib/config-update.tsx | 894 ++++++++++++------ .../endpoints/api/v1/internal/config.test.ts | 35 +- packages/shared-backend/src/config-file.ts | 2 +- packages/shared/src/config-eval.ts | 50 +- packages/shared/src/config/format.ts | 18 + .../shared/src/interface/admin-interface.ts | 27 +- packages/shared/src/schema-fields.ts | 12 +- pnpm-lock.yaml | 215 ++--- 16 files changed, 1190 insertions(+), 570 deletions(-) create mode 100644 apps/backend/src/app/api/latest/internal/config/github/commit/route.tsx diff --git a/apps/backend/scripts/spike-orchestrator-e2e.mts b/apps/backend/scripts/spike-orchestrator-e2e.mts index 1a8258d81..b5f75ad26 100644 --- a/apps/backend/scripts/spike-orchestrator-e2e.mts +++ b/apps/backend/scripts/spike-orchestrator-e2e.mts @@ -2,9 +2,10 @@ * End-to-end smoke test for the config-update repo agent against a REAL Vercel * Sandbox and a REAL GitHub repo. This is a scratch script, not a unit test. * - * It runs the single-phase apply flow: boot a sandbox (warm from the shared base + * It runs the two-phase review flow: boot a sandbox (warm from the shared base * snapshot if STACK_CONFIG_AGENT_BASE_SNAPSHOT_ID is set, else cold-install the - * agent SDK), clone the repo, agent edits the config, COMMIT + PUSH to the branch. + * agent SDK), clone the repo, agent edits the config, print the generated diff, + * then explicitly COMMIT + PUSH to the branch. * * WARNING: this pushes a commit to the target repo. Point SPIKE_OWNER, * SPIKE_REPO, and SPIKE_BRANCH at a throwaway repo/branch. @@ -18,6 +19,7 @@ import { execFileSync } from "child_process"; import { applyConfigUpdate, + commitConfigUpdate, type GithubRepoRef, } from "../src/lib/config/repo-agent"; @@ -53,21 +55,27 @@ async function main() { const getGithubToken = async () => githubToken(); log(`Target: ${REF.owner}/${REF.repo}@${REF.branch}`); - log("applyConfigUpdate (boot + clone + agent edit + push)…"); - const t2 = Date.now(); + log("applyConfigUpdate (boot + clone + agent edit)…"); + const t2 = performance.now(); const result = await applyConfigUpdate({ getGithubToken, ref: REF, completeConfig: COMPLETE_CONFIG, - commitMessage: "chore(hexclave): e2e smoke — set auth.allowSignUp=false", }); - log(`Done in ${((Date.now() - t2) / 1000).toFixed(0)}s`); + log(`Done in ${((performance.now() - t2) / 1000).toFixed(0)}s`); log(`Result: ${JSON.stringify(result)}`); - if (result.mode === "commit-to-branch") { - log(`✅ Pushed: ${result.commitUrl}`); - } else { + if (result.mode === "no-change") { log("⚠️ Agent produced no change (config already matched)."); + } else { + log(`Review diff has ${result.diff.length} characters. Committing reviewed changes…`); + const commit = await commitConfigUpdate({ + sandboxId: result.sandboxId, + getGithubToken, + ref: REF, + commitMessage: "chore(hexclave): e2e smoke — set auth.allowSignUp=false", + }); + log(`✅ Pushed: ${commit.commitUrl}`); } } diff --git a/apps/backend/src/app/api/latest/internal/config/github/apply/route.tsx b/apps/backend/src/app/api/latest/internal/config/github/apply/route.tsx index 33f54ddf6..c2fb7c833 100644 --- a/apps/backend/src/app/api/latest/internal/config/github/apply/route.tsx +++ b/apps/backend/src/app/api/latest/internal/config/github/apply/route.tsx @@ -4,9 +4,11 @@ import { recordConfigAgentRunProgress, recordConfigAgentRunResult, recordConfigAgentRunSandbox, + recordConfigAgentRunStage, + setConfigAgentRunAwaitingReview, tryStartConfigAgentRun, } from "@/lib/config"; -import { applyConfigUpdate, type GithubRepoRef } from "@/lib/config/repo-agent"; +import { applyConfigUpdate, stopConfigAgentSandbox, type GithubRepoRef } from "@/lib/config/repo-agent"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema"; @@ -107,6 +109,10 @@ export const POST = createSmartRouteHandler({ const onProgress = async (activity: string) => { await recordConfigAgentRunProgress({ projectId, branchId, progress: activity }); }; + // Stage updates drive the dashboard progress bar. + const onStage = async (stage: "initializing_sandbox" | "cloning_repo" | "agent_making_changes") => { + await recordConfigAgentRunStage({ projectId, branchId, stage }); + }; runAsynchronouslyAndWaitUntil(async () => { try { @@ -115,25 +121,32 @@ export const POST = createSmartRouteHandler({ // computed from the current branch override merged with this change. const completeConfig = await getCompleteBranchConfigForFile({ projectId, branchId, configUpdate }); - // Boot a sandbox (warm from the shared base snapshot if configured, else - // cold-install the agent SDK), clone the repo fresh, edit + commit + push. + // Boot a sandbox, clone the repo, run the agent. The sandbox stays alive + // in `awaiting_review` so the user can inspect the diff before committing. const result = await applyConfigUpdate({ getGithubToken, ref, completeConfig, - commitMessage, onSandboxId, + onStage, onProgress, }); - await recordConfigAgentRunResult({ - projectId, - branchId, - nowMs: Date.now(), - outcome: result.mode === "commit-to-branch" - ? { status: "success", commitUrl: result.commitUrl, newCommitHash: result.commitSha } - : { status: "no-change" }, - }); + if (result.mode === "no-change") { + await recordConfigAgentRunResult({ + projectId, + branchId, + nowMs: Date.now(), + outcome: { status: "no-change" }, + }); + } else { + // Transition to awaiting_review — sandbox stays alive. + await setConfigAgentRunAwaitingReview({ + projectId, + branchId, + diff: result.diff, + }); + } } catch (error) { captureError("config-github-apply", error); await recordConfigAgentRunResult({ diff --git a/apps/backend/src/app/api/latest/internal/config/github/commit/route.tsx b/apps/backend/src/app/api/latest/internal/config/github/commit/route.tsx new file mode 100644 index 000000000..ab073bf22 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/config/github/commit/route.tsx @@ -0,0 +1,99 @@ +import { + getBranchConfigOverrideSource, + recordConfigAgentRunResult, +} from "@/lib/config"; +import { commitConfigUpdate, stopConfigAgentSandbox, type GithubRepoRef } from "@/lib/config/repo-agent"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; +import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { StatusError, captureError } from "@hexclave/shared/dist/utils/errors"; + +// The commit+push itself is fast (~10 s) but we give generous room for sandbox +// reconnect latency and slow GitHub API responses. +export const maxDuration = 120; + +/** + * Commits and pushes the agent's already-applied changes from an `awaiting_review` + * sandbox. The user explicitly triggered this after reviewing the diff in the + * dashboard. Returns immediately and does the work in the background. + */ +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Commit config agent changes to GitHub", + description: "Commits and pushes the agent's already-applied config changes after user review.", + tags: ["Config"], + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema, + tenancy: adaptSchema, + }).defined(), + body: yupObject({ + github_access_token: yupString().defined(), + commit_message: yupString().optional(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + status: yupString().oneOf(["committing", "not-awaiting-review", "sandbox-expired"]).defined(), + }).defined(), + }), + handler: async (req) => { + const projectId = req.auth.tenancy.project.id; + const branchId = req.auth.tenancy.branchId; + + const source = await getBranchConfigOverrideSource({ projectId, branchId }); + if (source.type !== "pushed-from-github") { + throw new StatusError(StatusError.BadRequest, "This project's configuration is not linked to a GitHub repository."); + } + + const run = source.agent_run; + if (!run || run.status !== "awaiting_review") { + return { statusCode: 200, bodyType: "json", body: { status: "not-awaiting-review" } }; + } + + const sandboxId = run.sandbox_id; + if (!sandboxId) { + // Sandbox id was never recorded — can't commit. Treat as an error and clean up. + await recordConfigAgentRunResult({ + projectId, + branchId, + nowMs: Date.now(), + outcome: { status: "error", error: "Sandbox session expired. Please retry the update." }, + }); + return { statusCode: 200, bodyType: "json", body: { status: "sandbox-expired" } }; + } + + const githubToken = req.body.github_access_token; + const commitMessage = req.body.commit_message?.trim() || "chore(hexclave): update config from dashboard"; + const ref: GithubRepoRef = { owner: source.owner, repo: source.repo, branch: source.branch }; + const getGithubToken = async () => githubToken; + + runAsynchronouslyAndWaitUntil(async () => { + try { + const result = await commitConfigUpdate({ sandboxId, getGithubToken, ref, commitMessage }); + await recordConfigAgentRunResult({ + projectId, + branchId, + nowMs: Date.now(), + outcome: { status: "success", commitUrl: result.commitUrl, newCommitHash: result.commitSha }, + }); + } catch (error) { + captureError("config-github-commit", error); + await stopConfigAgentSandbox(sandboxId); + await recordConfigAgentRunResult({ + projectId, + branchId, + nowMs: Date.now(), + outcome: { status: "error", error: "Failed to commit and push the config changes." }, + }).catch((e) => captureError("config-github-commit-record-error", e)); + } + }); + + return { statusCode: 200, bodyType: "json", body: { status: "committing" } }; + }, +}); diff --git a/apps/backend/src/lib/config/index.tsx b/apps/backend/src/lib/config/index.tsx index 7ca6b850d..306b29574 100644 --- a/apps/backend/src/lib/config/index.tsx +++ b/apps/backend/src/lib/config/index.tsx @@ -494,7 +494,10 @@ export async function getCompleteBranchConfigForFile(options: { }): Promise> { const current = await rawQuery(globalPrismaClient, getBranchConfigOverrideQuery({ projectId: options.projectId, branchId: options.branchId })); const merged = override(current as Config, options.configUpdate as Config); - return normalize(merged, { onDotIntoNonObject: "ignore" }) as Record; + // Dashboard saves usually arrive as dot-notation deltas (for example + // `auth.allowSignUp: false`). The branch override can be empty, so missing + // parents must be materialized instead of silently dropping the pending edit. + return normalize(merged, { onDotIntoNonObject: "ignore", onDotIntoNull: "empty-object" }) as Record; } /** @@ -587,6 +590,10 @@ export async function recordConfigAgentRunProgress(options: { if (!source || source.type !== "pushed-from-github") return; if (source.agent_run?.status !== "running") return; const next: GithubConfigSource = { + // Stored cap (2000 chars): a coarse DB-size guard on the persisted feed. + // The runner already trims each line to 100 chars and keeps only the last + // ~6 lines (see buildRunnerScript), so this only bites pathological input; + // the two limits are independent on purpose (storage vs. in-sandbox feed). ...source, agent_run: { ...source.agent_run, progress: options.progress.slice(0, 2000) }, }; @@ -597,13 +604,80 @@ export async function recordConfigAgentRunProgress(options: { }); } +/** + * Records the current stage of an in-flight run for the dashboard progress bar. + * No-ops unless a run is still `running`. + */ +export async function recordConfigAgentRunStage(options: { + projectId: string, + branchId: string, + stage: "initializing_sandbox" | "cloning_repo" | "agent_making_changes", +}): Promise { + await retryTransaction(globalPrismaClient, async (tx) => { + const rows = await tx.$queryRaw<{ source: any }[]>` + SELECT "source" FROM "BranchConfigOverride" + WHERE "projectId" = ${options.projectId} AND "branchId" = ${options.branchId} + FOR UPDATE + `; + const source = rows[0]?.source; + if (!source || source.type !== "pushed-from-github") return; + if (source.agent_run?.status !== "running") return; + const next: GithubConfigSource = { + ...source, + agent_run: { ...source.agent_run, stage: options.stage }, + }; + await tx.branchConfigOverride.update({ + where: { projectId_branchId: { projectId: options.projectId, branchId: options.branchId } }, + data: { source: next as any }, + }); + }); +} + +/** + * Transitions a `running` agent run to `awaiting_review`: the agent has finished + * editing the config but has not yet committed or pushed. The diff is stored so + * the dashboard can display it. The sandbox_id must still be set (the sandbox + * stays alive — it will be used to commit+push when the user confirms, or + * hard-stopped on cancel). + */ +export async function setConfigAgentRunAwaitingReview(options: { + projectId: string, + branchId: string, + diff: string, +}): Promise<{ sandboxId: string | undefined }> { + return await retryTransaction(globalPrismaClient, async (tx) => { + const rows = await tx.$queryRaw<{ source: any }[]>` + SELECT "source" FROM "BranchConfigOverride" + WHERE "projectId" = ${options.projectId} AND "branchId" = ${options.branchId} + FOR UPDATE + `; + const source = rows[0]?.source; + if (!source || source.type !== "pushed-from-github") return { sandboxId: undefined }; + if (source.agent_run?.status !== "running") return { sandboxId: undefined }; + const next: GithubConfigSource = { + ...source, + agent_run: { + ...source.agent_run, + status: "awaiting_review", + stage: "awaiting_review", + diff: options.diff.slice(0, 100_000), + }, + }; + await tx.branchConfigOverride.update({ + where: { projectId_branchId: { projectId: options.projectId, branchId: options.branchId } }, + data: { source: next as any }, + }); + return { sandboxId: source.agent_run?.sandbox_id }; + }); +} + /** * Requests cancellation of the in-flight config agent run. Atomically flips a - * `running` run to the terminal `cancelled` status (so the original run's late - * result is ignored — see {@link recordConfigAgentRunResult}) and returns the - * sandbox id (if recorded) so the caller can hard-stop the sandbox. Returns - * `{ cancelled: false }` when no fresh run is in flight. (No revert: stopping the - * sandbox before the push undoes the change; a commit that already landed stays.) + * `running` or `awaiting_review` run to the terminal `cancelled` status and + * returns the sandbox id (if recorded) so the caller can hard-stop the sandbox. + * Returns `{ cancelled: false }` when no fresh run is in flight. (No revert: + * stopping the sandbox before the push undoes the change; a commit that already + * landed stays.) */ export async function cancelConfigAgentRun(options: { projectId: string, @@ -619,7 +693,7 @@ export async function cancelConfigAgentRun(options: { const source = rows[0]?.source; if (!source || source.type !== "pushed-from-github") return { cancelled: false }; const run = source.agent_run; - if (!run || run.status !== "running") return { cancelled: false }; + if (!run || (run.status !== "running" && run.status !== "awaiting_review")) return { cancelled: false }; const sandboxId: string | undefined = run.sandbox_id; const next: GithubConfigSource = { ...source, diff --git a/apps/backend/src/lib/config/repo-agent.tsx b/apps/backend/src/lib/config/repo-agent.tsx index e3c91d698..63fa80628 100644 --- a/apps/backend/src/lib/config/repo-agent.tsx +++ b/apps/backend/src/lib/config/repo-agent.tsx @@ -18,8 +18,9 @@ * * Each update then does a FRESH shallow clone of the target branch inside the * sandbox (we take that clone cost per write instead of caching it), runs the - * agent, commits, and pushes. The sandbox is destroyed afterwards and is NEVER - * snapshotted, so the token that lives in `origin` for the clone/push dies with it. + * agent, keeps the sandbox alive for review, then either commits/pushes or + * stops it on discard. The sandbox is NEVER snapshotted, so the token that lives + * in `origin` for the clone/push dies with it. * * Phases: * buildConfigAgentBaseSnapshot — one-off (build script): node24 + agent SDK + git @@ -49,6 +50,7 @@ const TOOLS_DIR = BASE; // agent SDK + runner live here, separate from the repo const DEFAULT_AGENT_MODEL = "anthropic/claude-haiku-4.5"; const DEFAULT_PROXY_URL = "https://api.hexclave.com/api/latest/integrations/ai-proxy"; const SANDBOX_TIMEOUT_MS = 900_000; +const REVIEW_SANDBOX_KEEPALIVE_MS = 5 * 60_000; const GIT_BOT_NAME = "Hexclave Config Bot"; const GIT_BOT_EMAIL = "config-bot@hexclave.com"; @@ -63,9 +65,7 @@ export type GithubRepoRef = { owner: string, repo: string, branch: string }; */ export type GithubTokenProvider = () => Promise; -export type ConfigUpdatePushResult = - | { mode: "commit-to-branch", branch: string, commitUrl: string, commitSha: string } - | { mode: "no-change" }; +export type ConfigUpdateCommitResult = { mode: "commit-to-branch", branch: string, commitUrl: string, commitSha: string }; export class ConfigRepoAgentError extends Error { constructor(message: string, options?: { cause?: unknown }) { @@ -92,6 +92,51 @@ function sandboxCreds(): SandboxCreds { }; } +async function getConfigAgentSandbox(sandboxId: string): Promise { + const creds = sandboxCreds(); + return await Sandbox.get({ sandboxId, token: creds.token, teamId: creds.teamId, projectId: creds.projectId }); +} + +async function stopSandboxWithContext(sandboxId: string, context: string): Promise { + try { + const sandbox = await getConfigAgentSandbox(sandboxId); + await sandbox.stop(); + } catch (error) { + captureError(context, error); + } +} + +async function keepSandboxAliveForReview(sandbox: Sandbox): Promise { + // The review UI needs the live sandbox because commit runs from the edited + // working tree. Top it back up to a five-minute review window once the diff is + // ready instead of relying on whatever time is left after agent execution. + const timeoutRemainingMs = sandbox.timeout; + if (timeoutRemainingMs < REVIEW_SANDBOX_KEEPALIVE_MS) { + await sandbox.extendTimeout(REVIEW_SANDBOX_KEEPALIVE_MS - timeoutRemainingMs); + } +} + +async function reportConfigAgentProgress(onProgress: AgentProgressSink | undefined, progress: string, context: string): Promise { + if (!onProgress) return; + try { + await onProgress(progress); + } catch (error) { + captureError(context, error); + } +} + +async function reportConfigAgentStage( + onStage: ((stage: "initializing_sandbox" | "cloning_repo" | "agent_making_changes") => Promise) | undefined, + stage: "initializing_sandbox" | "cloning_repo" | "agent_making_changes", +): Promise { + if (!onStage) return; + try { + await onStage(stage); + } catch (error) { + captureError("config-repo-agent-stage", error); + } +} + /** * Strip any tokenized remote URL (`https://x-access-token:@github.com/...`) * out of a string before it can be thrown, captured, persisted, or logged. The @@ -183,6 +228,9 @@ process.on("unhandledRejection", (e) => { status({ ok: false, error: "unhandledR const PROGRESS = ${JSON.stringify(`${TOOLS_DIR}/progress.json`)}; const recent = []; const base = (p) => (typeof p === "string" ? (p.split("/").pop() || p) : ""); +// In-sandbox feed cap: 100 chars/line, last 6 lines. Separate from the coarser +// 2000-char storage cap in recordConfigAgentRunProgress (config/index.tsx) — +// this one shapes what the user sees live; that one guards DB size. const emit = (s) => { recent.push(String(s).replace(/[\\r\\n]+/g, " ").slice(0, 100)); while (recent.length > 6) recent.shift(); try { writeFileSync(PROGRESS, JSON.stringify(recent)); } catch {} }; const describeTool = (name, inp) => { inp = inp || {}; @@ -268,7 +316,7 @@ async function pollAgentProgress( const text = redactTokens(lines.map((l) => String(l)).join("\n")).trim(); if (text && text !== last) { last = text; - await onProgress(text).catch(() => {}); + await reportConfigAgentProgress(onProgress, text, "config-repo-agent-progress-record"); } }; while (!finished) { @@ -393,55 +441,102 @@ export async function buildConfigAgentBaseSnapshot(onProgress?: (msg: string) => // Apply update (on save) // --------------------------------------------------------------------------- +/** + * The result of `applyConfigUpdate`. When the agent found and edited the config + * file, the sandbox is left alive (status `awaiting_review`) and the caller is + * responsible for either calling `commitConfigUpdate` or `stopConfigAgentSandbox`. + */ +export type ConfigUpdateApplyResult = + | { + mode: "awaiting_review", + sandboxId: string, + /** Unified git diff of the agent's changes; never contains secrets. */ + diff: string, + } + | { mode: "no-change" }; + export async function applyConfigUpdate(options: { getGithubToken: GithubTokenProvider, ref: GithubRepoRef, completeConfig: Record, - commitMessage?: string, onSandboxId?: (sandboxId: string) => Promise, + onStage?: (stage: "initializing_sandbox" | "cloning_repo" | "agent_making_changes") => Promise, onProgress?: AgentProgressSink, -}): Promise { - const { getGithubToken, ref, completeConfig, onSandboxId, onProgress } = options; +}): Promise { + const { getGithubToken, ref, completeConfig, onSandboxId, onStage, onProgress } = options; const creds = sandboxCreds(); - const commitMessage = options.commitMessage?.trim() || "chore(hexclave): update config from dashboard"; const step = async (msg: string) => { - if (onProgress) await onProgress(msg).catch(() => {}); + await reportConfigAgentProgress(onProgress, msg, "config-repo-agent-step-record"); }; const githubToken = await getGithubToken(); // fresh token for this boot - await step("Starting the config agent…"); + await reportConfigAgentStage(onStage, "initializing_sandbox"); + await step("Initializing the sandbox…"); const sandbox = await bootAgentSandbox(creds); + // Do NOT stop the sandbox in a finally block — when changes are found, we leave + // it alive for the user to review and commit. The caller must stop it. + await onSandboxId?.(sandbox.sandboxId); + // Configure the bot identity for the commit (idempotent; cheap on a warm boot). + await run(sandbox, "git", ["config", "--global", "user.email", GIT_BOT_EMAIL]); + await run(sandbox, "git", ["config", "--global", "user.name", GIT_BOT_NAME]); + + // Fresh shallow clone of just the target branch. The tokenized URL is used + // only for the clone; immediately after, we reset `origin` to a tokenless URL + // so the agent (which has Bash access) cannot read the token from `.git/config` + // or `git remote -v`. The token is re-injected only for our own push command. + await reportConfigAgentStage(onStage, "cloning_repo"); + await step(`Cloning ${ref.owner}/${ref.repo}@${ref.branch}…`); + await run(sandbox, "git", ["clone", "--depth", "1", "--single-branch", "--branch", ref.branch, tokenUrl(githubToken, ref), REPO_DIR]); + await run(sandbox, "git", ["-C", REPO_DIR, "remote", "set-url", "origin", tokenlessUrl(ref)]); + + // Agent writes the COMPLETE config to the file — no dependency install, no + // typecheck (the linked repo's CI validates the committed change). See buildUpdatePrompt. + await reportConfigAgentStage(onStage, "agent_making_changes"); + await step("Agent editing config…"); + await runAgent(sandbox, buildUpdatePrompt(completeConfig), onProgress); + + const dirty = (await runRaw(sandbox, "git", ["-C", REPO_DIR, "status", "--porcelain"])).stdout.trim(); + if (dirty === "") { + // No changes — stop the sandbox immediately (nothing to review). + await stopSandboxWithContext(sandbox.sandboxId, "config-repo-agent-no-change-stop"); + return { mode: "no-change" }; + } + + // Capture the diff before staging so the user can review it. Redact tokens + // (defensive; the diff shouldn't contain any, but be safe). + const diff = redactTokens( + (await runRaw(sandbox, "git", ["-C", REPO_DIR, "diff"])).stdout, + ); + + await keepSandboxAliveForReview(sandbox); + // Leave the sandbox alive — the user must confirm before we commit+push. + return { mode: "awaiting_review", sandboxId: sandbox.sandboxId, diff }; +} + +/** + * Commits and pushes the agent's already-applied changes from an existing sandbox + * that is currently in `awaiting_review` state. The sandbox is stopped afterwards. + * The GitHub token is obtained freshly — the user may have been reviewing for a + * while so we don't reuse the token from the original `applyConfigUpdate` call. + */ +export async function commitConfigUpdate(options: { + sandboxId: string, + getGithubToken: GithubTokenProvider, + ref: GithubRepoRef, + commitMessage: string, +}): Promise { + const { sandboxId, ref, commitMessage } = options; + const githubToken = await options.getGithubToken(); + const sandbox = await getConfigAgentSandbox(sandboxId); try { - await onSandboxId?.(sandbox.sandboxId); - // Configure the bot identity for the commit (idempotent; cheap on a warm boot). - await run(sandbox, "git", ["config", "--global", "user.email", GIT_BOT_EMAIL]); - await run(sandbox, "git", ["config", "--global", "user.name", GIT_BOT_NAME]); - - // Fresh shallow clone of just the target branch. The tokenized URL is used - // only for the clone; immediately after, we reset `origin` to a tokenless URL - // so the agent (which has Bash access) cannot read the token from `.git/config` - // or `git remote -v`. The token is re-injected only for our own push command. - await step(`Cloning ${ref.owner}/${ref.repo}@${ref.branch}…`); - await run(sandbox, "git", ["clone", "--depth", "1", "--single-branch", "--branch", ref.branch, tokenUrl(githubToken, ref), REPO_DIR]); - await run(sandbox, "git", ["-C", REPO_DIR, "remote", "set-url", "origin", tokenlessUrl(ref)]); - - // Agent writes the COMPLETE config to the file — no dependency install, no - // typecheck (the linked repo's CI validates the committed change). See buildUpdatePrompt. - await runAgent(sandbox, buildUpdatePrompt(completeConfig), onProgress); - - const dirty = (await runRaw(sandbox, "git", ["-C", REPO_DIR, "status", "--porcelain"])).stdout.trim(); - if (dirty === "") { - return { mode: "no-change" }; - } await run(sandbox, "git", ["-C", REPO_DIR, "add", "-A"]); await run(sandbox, "git", ["-C", REPO_DIR, "commit", "-m", commitMessage]); const commitSha = await gitHead(sandbox); - await step("Pushing the commit…"); - // Re-inject the token for the push only (origin was reset to tokenless after clone). + // Re-inject the token for the push only. await run(sandbox, "git", ["-C", REPO_DIR, "push", tokenUrl(githubToken, ref), `HEAD:refs/heads/${ref.branch}`]); return { mode: "commit-to-branch", branch: ref.branch, commitUrl: `https://github.com/${ref.owner}/${ref.repo}/commit/${commitSha}`, commitSha }; } finally { - await sandbox.stop().catch(() => {}); + await stopSandboxWithContext(sandboxId, "config-repo-agent-commit-stop"); } } @@ -456,11 +551,5 @@ export async function applyConfigUpdate(options: { * undoes the change; if a commit already landed, it stays (no revert). */ export async function stopConfigAgentSandbox(sandboxId: string): Promise { - const creds = sandboxCreds(); - try { - const sandbox = await Sandbox.get({ sandboxId, token: creds.token, teamId: creds.teamId, projectId: creds.projectId }); - await sandbox.stop(); - } catch (error) { - captureError("config-repo-agent-cancel-stop", error); - } + await stopSandboxWithContext(sandboxId, "config-repo-agent-cancel-stop"); } diff --git a/apps/backend/src/lib/local-emulator.ts b/apps/backend/src/lib/local-emulator.ts index 452bc4fc5..ba03fc6cb 100644 --- a/apps/backend/src/lib/local-emulator.ts +++ b/apps/backend/src/lib/local-emulator.ts @@ -1,7 +1,7 @@ import { globalPrismaClient } from "@/prisma-client"; import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/config-authoring"; import { renderConfigFileContent } from "@hexclave/shared/dist/config-rendering"; -import { detectImportPackageFromDir, evalConfigFileContent } from "@hexclave/shared/dist/config-eval"; +import { detectImportPackageFromDir, evalConfigFileContent, type ParsedConfigValue } from "@hexclave/shared/dist/config-eval"; import { isValidConfig } from "@hexclave/shared/dist/config/format"; import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@hexclave/shared/dist/local-emulator"; import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; @@ -18,7 +18,7 @@ export const LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE = export const LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV = "STACK_LOCAL_EMULATOR_HOST_MOUNT_ROOT"; export const LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE = showOnboardingHexclaveConfigValue; -type LocalEmulatorConfigValue = Record | typeof LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE; +type LocalEmulatorConfigValue = ParsedConfigValue; export function isLocalEmulatorEnabled() { return getEnvVariable("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "") === "true"; @@ -76,14 +76,10 @@ async function readConfigContent(filePath: string): Promise { async function readConfigValueFromFile(filePath: string): Promise { const content = await readConfigContent(filePath); try { - const result = evalConfigFileContent(content, filePath); - if (typeof result === "string") { - if (result !== LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE) { - throw new Error(`Unexpected string config value: ${JSON.stringify(result)}`); - } - return result; - } - return result; + // `evalConfigFileContent` already guarantees the value is a config object or + // the `"show-onboarding"` sentinel (it throws otherwise), so no further + // shape check is needed here. + return evalConfigFileContent(content, filePath); } catch (e) { const message = e instanceof Error ? e.message : String(e); throw new StatusError(StatusError.BadRequest, `Error evaluating config in ${filePath}: ${message}`); diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index d9544d15e..8a678b157 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@ai-sdk/react": "^3.0.72", + "@pierre/diffs": "^1.2.11", "@assistant-ui/react": "^0.10.24", "@assistant-ui/react-ai-sdk": "^0.10.14", "@assistant-ui/react-markdown": "^0.10.5", diff --git a/apps/dashboard/src/lib/config-agent-run.tsx b/apps/dashboard/src/lib/config-agent-run.tsx index 3400682ab..41afe4f66 100644 --- a/apps/dashboard/src/lib/config-agent-run.tsx +++ b/apps/dashboard/src/lib/config-agent-run.tsx @@ -2,17 +2,18 @@ import { useAdminAppIfExists } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; import { ActionDialog } from "@/components/ui/action-dialog"; +import { GitBranch } from "@phosphor-icons/react"; +import type { StackAdminApp } from "@hexclave/next"; import { captureError } from "@hexclave/shared/dist/utils/errors"; import React, { useCallback, useEffect, useRef, useState } from "react"; -import { ConfigAgentActivityFeed, useGithubRunActive } from "./config-update"; +import { ConfigAgentRunProgressContent, type AgentStage, getAdminInterface, isGithubPushedSourceWithAgentRun, useGithubRunActive, type GithubPushedSourceWithAgentRun } from "./config-update"; /** * Watches the linked-GitHub config source for an in-flight agent run and, when - * one is running, pops a NON-DISMISSIBLE progress modal — so opening the project - * (in any tab, or after a reload) while a run is going surfaces it and prevents - * starting a conflicting edit. The only way out is Cancel (hard-stops the - * sandbox) or the run reaching a terminal status. + * one is running, pops a NON-DISMISSIBLE progress modal so opening the project + * in another tab surfaces it and prevents starting a conflicting edit. Review + * and commit stay owned by the push dialog that started the run. * * Mounted once per project (inside `AdminAppProvider`). It deliberately stays * silent for runs THIS tab started via the push dialog — that dialog owns the @@ -25,11 +26,12 @@ const ACTIVE_POLL_MS = 3_000; // a run is on screen — poll tightly const LINKED_IDLE_POLL_MS = 10_000; // linked repo, no run — watch for new runs const UNLINKED_POLL_MS = 30_000; // not a GitHub-linked project — back off -async function readPushedConfigSource(adminApp: unknown): Promise { - const iface = (adminApp as any)?._interface; +async function readPushedConfigSource(adminApp: StackAdminApp | null): Promise { + const iface = getAdminInterface(adminApp); if (iface == null || typeof iface.getPushedConfigSource !== "function") return null; try { - return await iface.getPushedConfigSource(); + const source: unknown = await iface.getPushedConfigSource(); + return isGithubPushedSourceWithAgentRun(source) ? source : null; } catch { return null; // transient — try again next tick } @@ -43,13 +45,14 @@ export function ConfigAgentRunWatcher() { const [sourceInfo, setSourceInfo] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [activity, setActivity] = useState(null); + const [stage, setStage] = useState(null); + const [startedAt, setStartedAt] = useState(0); - // Keep the latest phase readable inside the polling loop without retriggering it. const phaseRef = useRef(phase); phaseRef.current = phase; const handleCancel = useCallback(async (): Promise<"prevent-close" | undefined> => { - const iface = (adminApp as any)?._interface; + const iface = getAdminInterface(adminApp); if (iface == null || typeof iface.cancelConfigAgentRun !== "function") { setErrorMessage("This dashboard build can't cancel a config run. Please refresh and try again."); setPhase("error"); @@ -60,7 +63,6 @@ export function ConfigAgentRunWatcher() { try { await iface.cancelConfigAgentRun(); } catch (error) { - // Best-effort: the poll loop still settles if the cancel landed. captureError("config-agent-watcher-cancel", error); } return "prevent-close"; @@ -68,13 +70,11 @@ export function ConfigAgentRunWatcher() { useEffect(() => { if (!adminApp) return; - // Mutable holder (not a `let`) so eslint's flow analysis doesn't treat the - // post-await `stopped` check as always-false — the flag is flipped in cleanup. const loop = { stopped: false }; let timer: ReturnType | undefined; - const apply = (source: any): number => { - if (!source || source.type !== "pushed-from-github") { + const apply = (source: GithubPushedSourceWithAgentRun | null): number => { + if (source == null) { if (phaseRef.current !== "error") setPhase("hidden"); return UNLINKED_POLL_MS; } @@ -88,11 +88,11 @@ export function ConfigAgentRunWatcher() { } if (status === "running") { setActivity(typeof source.agent_run?.progress === "string" ? source.agent_run.progress : null); + if (source.agent_run?.stage != null) setStage(source.agent_run.stage); + if (typeof source.agent_run?.started_at === "number") setStartedAt(source.agent_run.started_at); if (phaseRef.current !== "cancelling") setPhase("running"); return ACTIVE_POLL_MS; } - // Terminal (or no run). Only surface an error if we were actively showing - // this run — a stale error from before the page loaded shouldn't pop a modal. if (status === "error" && (phaseRef.current === "running" || phaseRef.current === "cancelling")) { setErrorMessage(source.agent_run?.error ?? "The config agent failed to apply the change."); setPhase("error"); @@ -104,7 +104,7 @@ export function ConfigAgentRunWatcher() { const tick = async () => { const source = await readPushedConfigSource(adminApp); - if (loop.stopped) return; // unmounted while the request was in flight + if (loop.stopped) return; const delay = apply(source); timer = setTimeout(() => void tick(), delay); }; @@ -139,27 +139,22 @@ export function ConfigAgentRunWatcher() { -
-

- {phase === "cancelling" - ? "Cancelling the update and stopping the agent…" - : "Applying your change in a sandbox and committing it to GitHub. This can take a couple of minutes."} -

- {phase !== "cancelling" && activity != null && activity.trim().length > 0 && ( - - )} - {errorMessage != null && ( -

{errorMessage}

- )} -
+
); } diff --git a/apps/dashboard/src/lib/config-update.tsx b/apps/dashboard/src/lib/config-update.tsx index 249495656..22e6fade8 100644 --- a/apps/dashboard/src/lib/config-update.tsx +++ b/apps/dashboard/src/lib/config-update.tsx @@ -3,18 +3,74 @@ import { Link } from "@/components/link"; import { ActionDialog } from "@/components/ui/action-dialog"; import { fetchWithRemoteDevelopmentEnvironmentBrowserSecret, RemoteDevelopmentEnvironmentBrowserSecretRedirectingError } from "@/app/remote-development-environment-browser-secret-client"; +import { DesignButton, DesignDialog, DesignDialogClose } from "@/components/design-components"; import { useDashboardInternalUser } from "@/lib/dashboard-user"; import { getPublicEnvVar } from "@/lib/env"; +import { ArrowsClockwise, GitBranch, GitCommit } from "@phosphor-icons/react"; import type { OAuthConnection, 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 { HexclaveAssertionError, captureError } from "@hexclave/shared/dist/utils/errors"; import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; import React, { createContext, Suspense, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react"; +import type { FileDiffProps } from "@pierre/diffs/react"; +import type { FileDiffMetadata } from "@pierre/diffs"; import { GITHUB_SCOPE_REQUIREMENTS } from "./github-api"; +/** + * 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 | 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; +} + type GithubPushedSource = Extract; +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 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, + }, +}; + +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"; +} + +function currentEpochMsFromPerformance(): number { + return performance.timeOrigin + performance.now(); +} + type ConfigUpdateDialogState = { isOpen: boolean, adminApp: StackAdminApp | null, @@ -39,24 +95,92 @@ export function useGithubRunActive(): boolean { return useContext(ConfigUpdateDialogContext)?.githubRunActive ?? false; } +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); +} + /** - * Renders the agent's live activity feed (a sanitized, server-provided list of - * recent actions like "Editing hexclave.config.ts" / "Running: git push"). Shared - * between the push dialog and the page-load watcher. + * 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 ConfigAgentActivityFeed({ activity }: { activity: string }) { - const lines = activity.split("\n").map((l) => l.trim()).filter((l) => l.length > 0); - if (lines.length === 0) { - return null; - } +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 ( -
- {lines.map((line, index) => { - const isLast = index === lines.length - 1; +
+ {STAGE_STEPS.map((step, idx) => { + const isDone = idx < activeIdx; + const isActive = idx === activeIdx; + const isPending = idx > activeIdx; + return ( -
- {isLast ? "▸" : "·"} - {line} +
+ {/* Step indicator */} +
+ {isDone ? ( + + + + ) : ( + {idx + 1} + )} +
+ + {/* Step label */} +
+
+ {step.label} + {isActive && ( + + {overallElapsed}s + + )} +
+ {isActive && activity != null && activity.trim().length > 0 && ( +
+ + {activity.split("\n").filter((l) => l.trim()).at(-1)} +
+ )} +
); })} @@ -64,6 +188,98 @@ export function ConfigAgentActivityFeed({ activity }: { activity: string }) { ); } +export function ConfigAgentRunProgressContent({ + isCancelling, + stage, + startedAt, + activity, + errorMessage, +}: { + isCancelling: boolean, + stage: AgentStage | null | undefined, + startedAt: number, + activity?: string | null, + errorMessage?: string | null, +}) { + return ( +
+ {isCancelling ? ( +

Cancelling the update and stopping the agent…

+ ) : ( + + )} + {errorMessage != null && ( +

{errorMessage}

+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Diff viewer +// --------------------------------------------------------------------------- + +/** + * 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>, + 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 ( +
+ {renderer.files.map((fileDiff, index) => ( + + ))} +
+ ); + } + + // Fallback: raw monospace diff + return ( +
+      {diff}
+    
+ ); +} + /** * Provider component that enables the config update dialog functionality. * Wrap your app or page with this provider to use the `updateConfig` utility. @@ -89,15 +305,15 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React // progress modal and the backend would reject a second run anyway. The mapped // `PushedConfigSource` drops `agent_run`, so read the raw interface source. if (source.type === "pushed-from-github") { - const iface = (adminApp as any)?._interface; + const iface = getAdminInterface(adminApp); if (iface != null && typeof iface.getPushedConfigSource === "function") { - let rawSource: any = null; + let rawSource: unknown = null; try { rawSource = await iface.getPushedConfigSource(); } catch { // transient — fall through to the normal dialog rather than blocking } - if (rawSource?.type === "pushed-from-github" && rawSource.agent_run?.status === "running") { + if (isGithubPushedSourceWithAgentRun(rawSource) && rawSource.agent_run?.status === "running") { return false; } } @@ -233,161 +449,213 @@ type GithubPushDialogProps = { }; /** - * Renders the "Push to GitHub" dialog. Detects whether the dashboard user has - * a GitHub account connected; if not, walks them through linking one first. - * Once a connection is available, commits a config-file edit to the linked - * repo/branch via the Contents API. - * - * On success, `onSettle(true)` is called so the surrounding - * `ConfigUpdateDialogProvider` then mirrors the change into Hexclave's - * cloud config for immediate UI feedback. Eventually the GitHub Actions - * workflow will re-push the canonical config from the freshly-committed file. + * 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" }; -type GithubPushHandlers = { - push: () => Promise<"prevent-close" | undefined>, - connect: () => Promise<"prevent-close" | undefined>, - cancel: () => Promise<"prevent-close" | undefined>, -}; - -// "idle" before/after the run; "running" once the agent is started (dialog is -// non-dismissible, Cancel aborts the run); "cancelling" after Cancel is clicked. -type RunPhase = "idle" | "running" | "cancelling"; +// "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 `ActionDialog` synchronously (no suspending hooks) so - * opening the dialog doesn't bubble a Suspense promise up to the dashboard - * root and blank the page. The suspending pieces (current user, connected - * accounts, OAuth token probe) live in `GithubPushBody`, wrapped in a local - * `Suspense` boundary whose fallback mirrors the dialog body except that the - * "Push to GitHub" button stays disabled while we resolve. + * Outer shell: renders the DesignDialog synchronously; the Suspense-suspending + * body (scope check) is isolated inside. */ function GithubPushDialog({ open, adminApp, source, configUpdate, projectId, onSettle }: GithubPushDialogProps) { - // Status starts as "checking" so the initial render shows a disabled - // "Push to GitHub" button — matching what we want during Suspense fallback. const [scopeStatus, setScopeStatus] = useState("checking"); - const [runPhase, setRunPhase] = useState("idle"); - const handlersRef = useRef(null); + const [phase, setPhase] = useState("idle"); + const [stage, setStage] = useState(null); + const [startedAt, setStartedAt] = useState(0); + const [activity, setActivity] = useState(null); + const [diff, setDiff] = useState(null); + const [commitMessage, setCommitMessage] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); - const dispatch = useCallback( - (key: keyof GithubPushHandlers) => async (): Promise<"prevent-close" | undefined> => { - // While the Suspense fallback is showing, handlers aren't registered - // yet. In that window the button is disabled anyway, but we guard - // defensively and prevent close if somehow clicked. - return (await handlersRef.current?.[key]()) ?? "prevent-close"; - }, - [], - ); + // Expose imperative handles from the body (which can suspend) to the outer shell. + const handlersRef = useRef<{ + push: () => Promise, + connect: () => Promise, + cancel: () => Promise, + commit: () => Promise, + } | null>(null); - // Once the run is in flight the dialog is non-dismissible: the only exit is - // Cancel (which aborts + reverts) or the run reaching a terminal status. - const isRunning = runPhase !== "idle"; - - const okButton = (() => { - if (isRunning) return undefined; - switch (scopeStatus) { - case "no-account": { - return { label: "Connect with GitHub", onClick: dispatch("connect") }; - } - case "checking": { - return { - label: "Push to GitHub", - onClick: async (): Promise<"prevent-close" | undefined> => "prevent-close", - props: { disabled: true }, - }; - } - case "ok": { - return { label: "Push to GitHub", onClick: dispatch("push") }; - } - case "missing-scopes": { - return { label: "Reconnect with GitHub", onClick: dispatch("connect") }; - } - } - })(); - - const cancelButton = isRunning - ? { - label: runPhase === "cancelling" ? "Cancelling…" : "Cancel update", - onClick: dispatch("cancel"), - props: { disabled: runPhase === "cancelling" }, - } - : { - label: "Cancel", - onClick: async () => { - onSettle(false); - }, - }; + const dialogContext = useContext(ConfigUpdateDialogContext); + const isNonDismissible = phase === "running" || phase === "cancelling" || phase === "committing"; const description = (() => { - if (isRunning) { - return `Updating ${source.owner}/${source.repo}@${source.branch} via the config agent. This can take a couple of minutes.`; - } - switch (scopeStatus) { - case "no-account": { - return "Connect a GitHub account to push configuration changes to this repository."; + 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 "checking": { - return "Checking GitHub permissions..."; + case "running": + case "cancelling": { + return `Applying your change in a sandbox — ${source.owner}/${source.repo}@${source.branch}`; } - case "ok": { - return `This will commit your change to ${source.owner}/${source.repo}@${source.branch}.`; + case "awaiting_review": { + return `Review the changes before committing to ${source.branch}.`; } - case "missing-scopes": { - return "Your linked GitHub account is missing the \"repo\" and \"workflow\" permissions required to push configuration changes. Reconnect to grant them."; + case "committing": { + return `Pushing to ${source.owner}/${source.repo}@${source.branch}…`; } } })(); + // Footer buttons + const footer = (() => { + if (phase === "running") { + return ( +
+ { await handlersRef.current?.cancel(); }} + > + Cancel + +
+ ); + } + if (phase === "cancelling") { + return ( + + Cancelling… + + ); + } + if (phase === "awaiting_review") { + return ( +
+ { await handlersRef.current?.cancel(); }} + > + Discard + +
+
+ + setCommitMessage(e.target.value)} + /> + { await handlersRef.current?.commit(); }} + > + + Commit + +
+
+ ); + } + if (phase === "committing") { + return ( +
+ + Committing… + +
+ ); + } + // idle + return ( +
+ + { onSettle(false); }}> + Cancel + + + {scopeStatus === "no-account" || scopeStatus === "missing-scopes" ? ( + { await handlersRef.current?.connect(); }}> + {scopeStatus === "no-account" ? "Connect with GitHub" : "Reconnect with GitHub"} + + ) : ( + { await handlersRef.current?.push(); }} + disabled={scopeStatus === "checking"} + loading={scopeStatus === "checking"} + > + + Start update + + )} +
+ ); + })(); + + // Dialog size grows when showing the diff + const dialogSize = phase === "awaiting_review" ? "3xl" : "lg"; + return ( - onSettle(false)} - preventClose={isRunning} - title="Push Configuration to GitHub" + onOpenChange={(o) => { + if (o || isNonDismissible) return; + onSettle(false); + }} + size={dialogSize} + icon={GitBranch} + title="Push configuration to GitHub" description={description} - okButton={okButton} - cancelButton={cancelButton} + hideTopCloseButton={isNonDismissible} + footer={footer} + contentProps={{ onPointerDownOutside: isNonDismissible ? (e) => e.preventDefault() : undefined, onEscapeKeyDown: isNonDismissible ? (e) => e.preventDefault() : undefined }} > - }> + Loading…
}> - - ); -} - -function GithubPushBodyFallback({ projectId }: { projectId: string | undefined }) { - // Static body shown during the initial Suspense — no commit input yet - // (we don't know whether push is even available), just the unlink hint - // so the dialog "looks normal except the button is disabled". - return ( -
-

- - If your configuration is no longer on GitHub, you can unlink it in{" "} - - Project Settings - . - -

-
+ ); } @@ -397,9 +665,27 @@ type GithubPushBodyProps = { configUpdate: EnvironmentConfigOverrideOverride | null, projectId: string | undefined, onSettle: (result: boolean) => void, - onScopeStatusChange: (status: ScopeCheck["status"]) => void, - onRunPhaseChange: (phase: RunPhase) => void, - handlersRef: React.MutableRefObject, + 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, + connect: () => Promise, + cancel: () => Promise, + commit: () => Promise, + } | null>, + dialogContext: { setGithubRunActive: (v: boolean) => void } | null, }; function GithubPushBody({ @@ -408,52 +694,42 @@ function GithubPushBody({ configUpdate, projectId, onSettle, + phase, + stage, + startedAt, + activity, + diff, + commitMessage, + errorMessage, onScopeStatusChange, - onRunPhaseChange, + onPhaseChange, + onStageChange, + onStartedAtChange, + onActivityChange, + onDiffChange, + onErrorChange, handlersRef, + dialogContext, }: GithubPushBodyProps) { - const dialogContext = useContext(ConfigUpdateDialogContext); const user = useDashboardInternalUser(); const githubAccounts = user.useConnectedAccounts().filter((account) => account.provider === "github"); - - // Stable dep for the scope-check effect — re-run only when the set of - // connections actually changes, not on every parent render. const githubAccountsKey = githubAccounts.map((a) => a.providerAccountId).join("|"); const [scopeCheck, setScopeCheck] = useState( githubAccounts.length === 0 ? { status: "no-account" } : { status: "checking" }, ); - const [commitMessage, setCommitMessage] = useState(""); - const [errorMessage, setErrorMessage] = useState(null); - const [progressMessage, setProgressMessage] = useState(null); - const [activity, setActivity] = useState(null); - const placeholderCommitMessage = "Update Hexclave configuration"; + const placeholderCommitMessage = "chore(hexclave): update config from dashboard"; - // Sync our local status string up to the dialog shell so it can pick the - // right button label / description without itself needing to suspend. - // `useLayoutEffect` (not `useEffect`) so the shell's "checking" placeholder - // never reaches the screen for users whose initial state is actually - // "no-account" — the sync runs before the browser paints the first frame - // after the Suspense fallback resolves. useLayoutEffect(() => { onScopeStatusChange(scopeCheck.status); }, [scopeCheck.status, onScopeStatusChange]); - // Probe each connected GitHub account for a token that already covers - // `repo` + `workflow`. The dashboard user may have multiple GitHub - // connections; only one needs to carry the elevated scopes. We pre-flight - // here (rather than on Push click) so the user doesn't waste a typed commit - // message on a redirect, since `linkConnectedAccount` is a full page nav. useEffect(() => { if (githubAccounts.length === 0) { setScopeCheck({ status: "no-account" }); return; } - // Mutable holder rather than a `let` so TS sees the reassignment in the - // cleanup callback as a real write; otherwise its flow analysis narrows - // the closure read to its initial value and the `cancelled` checks below - // are flagged as constant-condition errors. const cancelToken = { cancelled: false }; setScopeCheck({ status: "checking" }); runAsynchronously(async () => { @@ -462,7 +738,6 @@ function GithubPushBody({ try { tokenResult = await account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS }); } catch { - // Transport/cache failures — fall through and try the next account. continue; } if (cancelToken.cancelled) return; @@ -476,101 +751,112 @@ function GithubPushBody({ return () => { cancelToken.cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- githubAccountsKey is the stable identity for githubAccounts + // eslint-disable-next-line react-hooks/exhaustive-deps -- githubAccountsKey }, [githubAccountsKey]); - const handlePush = useCallback(async (): Promise<"prevent-close" | undefined> => { + const handlePush = useCallback(async () => { if (configUpdate == null) { - setErrorMessage("No configuration changes to push."); - return "prevent-close"; + onErrorChange("No configuration changes to push."); + return; } if (scopeCheck.status !== "ok") { - setErrorMessage("Connect a GitHub account with the required scopes before pushing changes."); - return "prevent-close"; + onErrorChange("Connect a GitHub account with the required scopes before pushing changes."); + return; } - // The admin app's underlying interface carries the new agent endpoints. We - // reach it directly (rather than via a generated app method) to keep this - // feature self-contained in `@hexclave/shared`. - const adminInterface = (adminApp as any)?._interface; + const adminInterface = getAdminInterface(adminApp); if (adminInterface == null || typeof adminInterface.applyConfigViaAgent !== "function") { - setErrorMessage("This dashboard build can't push config to GitHub. Please refresh and try again."); - return "prevent-close"; + onErrorChange("This dashboard build can't push config to GitHub. Please refresh and try again."); + return; } - setErrorMessage(null); - setProgressMessage("Starting the config agent…"); + onErrorChange(null); try { - // The dashboard user's own GitHub token, used transiently server-side for - // the sandbox's git push — never persisted, never sent to the agent. const tokenResult = await scopeCheck.account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS }); if (tokenResult.status !== "ok") { - setProgressMessage(null); - setErrorMessage("Could not get a GitHub token with the required permissions. Reconnect your GitHub account and try again."); - return "prevent-close"; + 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, - commitMessage: commitMessage.trim().length > 0 ? commitMessage : placeholderCommitMessage, + // Pass a placeholder; the real commit message is gathered at review time. + commitMessage: placeholderCommitMessage, githubAccessToken: tokenResult.data.accessToken, }); if (start.status === "already-running") { - setProgressMessage(null); - setErrorMessage("Another configuration update is already running for this project. Wait for it to finish, then try again."); - return "prevent-close"; + onErrorChange("Another configuration update is already running for this project. Wait for it to finish, then try again."); + return; } - // The run is now in flight. Lock the dialog (non-dismissible; Cancel aborts) - // and flag the run as managed by this tab so the page-load watcher stays out - // of the way until we settle. - const runStartedAt = Date.now(); + const runStartedAtWallMs = currentEpochMsFromPerformance(); + const runStartedAtMonotonicMs = performance.now(); + onStartedAtChange(runStartedAtWallMs); dialogContext?.setGithubRunActive(true); - onRunPhaseChange("running"); - setActivity(null); + onPhaseChange("running"); + onActivityChange(null); + onStageChange("initializing_sandbox"); - // Poll the source's agent_run until the background job reports a terminal - // status. The agent does real repo work (edit + typecheck + commit), so - // this can take a couple of minutes. - setProgressMessage("Applying your change in a sandbox and committing to GitHub…"); - const deadline = Date.now() + 8 * 60_000; - while (Date.now() < deadline) { + // 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: any; + let latest: unknown; try { latest = await adminInterface.getPushedConfigSource(); } catch { - continue; // transient — keep polling + continue; } - const run = latest?.type === "pushed-from-github" ? latest.agent_run : null; - // Ignore stale agent_run entries from a previous run (or read-replica lag). - // The run we just started must have started_at >= our request timestamp. - if (run == null || run.status === "running" || (typeof run.started_at === "number" && run.started_at < runStartedAt - 5000)) { - if (run != null && run.status === "running" && typeof run?.progress === "string") setActivity(run.progress); + 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; } - setProgressMessage(null); - onRunPhaseChange("idle"); + // 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") { - setErrorMessage("The config agent failed to apply your change."); - return "prevent-close"; + onPhaseChange("idle"); + onStageChange(null); + onErrorChange("The config agent failed to apply your change."); + return; } if (run.status === "cancelled") { - // Cancelled (here or from another tab): discard the pending edit. + onPhaseChange("idle"); + onStageChange(null); onSettle(false); - return undefined; + return; } - // success or no-change: mirror into cloud config for immediate UI feedback. + 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 undefined; + return; } - setProgressMessage(null); - onRunPhaseChange("idle"); dialogContext?.setGithubRunActive(false); - setErrorMessage("Timed out waiting for the config agent. Your change may still be in progress — check the linked repository."); - return "prevent-close"; + 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, @@ -580,100 +866,144 @@ function GithubPushBody({ configFilePath: source.configFilePath, cause: error, }); - setProgressMessage(null); - onRunPhaseChange("idle"); dialogContext?.setGithubRunActive(false); - setErrorMessage("Unknown error pushing to GitHub."); - return "prevent-close"; + onPhaseChange("idle"); + onStageChange(null); + onErrorChange("Unknown error pushing to GitHub."); } - }, [adminApp, commitMessage, configUpdate, dialogContext, onRunPhaseChange, onSettle, projectId, scopeCheck, source]); + }, [adminApp, configUpdate, dialogContext, onActivityChange, onDiffChange, onErrorChange, onPhaseChange, onSettle, onStageChange, onStartedAtChange, projectId, scopeCheck, source]); - // Cancel an in-flight run: hard-stops the sandbox server-side. The poll loop - // above then observes the terminal `cancelled` status and settles the dialog - // (discarding the pending edit). No revert — if the agent already pushed, the - // commit stays. - const handleCancel = useCallback(async (): Promise<"prevent-close" | undefined> => { - const adminInterface = (adminApp as any)?._interface; + const handleCancel = useCallback(async () => { + const adminInterface = getAdminInterface(adminApp); if (adminInterface == null || typeof adminInterface.cancelConfigAgentRun !== "function") { - setErrorMessage("This dashboard build can't cancel a config run. Please refresh and try again."); - return "prevent-close"; + onErrorChange("This dashboard build can't cancel a config run. Please refresh and try again."); + return; } - onRunPhaseChange("cancelling"); - setProgressMessage("Cancelling the update…"); + onPhaseChange("cancelling"); try { await adminInterface.cancelConfigAgentRun(); } catch (error) { - // Best-effort: the poll loop still observes the terminal status if the - // cancel landed; surface nothing and keep the dialog open. captureError("config-update-github-cancel", error); } - return "prevent-close"; - }, [adminApp, onRunPhaseChange]); + // The poll loop in handlePush will observe the terminal `cancelled` status and settle. + }, [adminApp, onErrorChange, onPhaseChange]); - const handleConnect = useCallback(async (): Promise<"prevent-close" | undefined> => { - // Full-page redirect to the OAuth provider. When scopes are missing on - // an existing connection, `getOrLinkConnectedAccount` still redirects - // because none of the present tokens satisfies the scope set. Returning - // `prevent-close` is defensive — in practice the redirect happens first. + 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."; - setErrorMessage(message); - return "prevent-close"; + onErrorChange(message); } - return "prevent-close"; - }, [user]); + }, [onErrorChange, user]); - // Expose the latest handlers to the dialog shell. A ref (rather than - // calling up via state) avoids re-rendering the shell on every handler - // identity change, which would also reset the okButton onClick reference. useEffect(() => { - handlersRef.current = { push: handlePush, connect: handleConnect, cancel: handleCancel }; - }, [handlersRef, handlePush, handleConnect, handleCancel]); + handlersRef.current = { push: handlePush, connect: handleConnect, cancel: handleCancel, commit: handleCommit }; + }, [handlersRef, handlePush, handleConnect, handleCancel, handleCommit]); return (
- {scopeCheck.status === "ok" && ( -
- - setCommitMessage(e.target.value)} - /> -

- Committing to {source.configFilePath} on{" "} - {source.branch}. -

-
+ {/* Stage progress bar — shown while running */} + {(phase === "running" || phase === "cancelling") && ( + )} - {progressMessage != null && ( -

- {progressMessage} -

+ + {/* Diff viewer — shown when awaiting review */} + {phase === "awaiting_review" && diff != null && diff.trim().length > 0 && ( + )} - {progressMessage != null && activity != null && activity.trim().length > 0 && ( - + + {/* Error */} + {phase !== "running" && phase !== "cancelling" && errorMessage != null && ( +

{errorMessage}

)} - {errorMessage != null && ( -

- {errorMessage} -

- )} -

- - If your configuration is no longer on GitHub, you can unlink it in{" "} + + {/* Unlink hint — shown in idle state */} + {phase === "idle" && ( +

+ If your configuration is no longer on GitHub, you can{" "} - Project Settings + unlink it in Project Settings . - -

+

+ )}
); } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts index 66a29dfa3..49241b5bd 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts @@ -421,7 +421,7 @@ describe("oauth config", () => { expect(invalidTypeResponse).toMatchInlineSnapshot(` NiceResponse { "status": 400, - "body": "auth.oauth.providers.invalid.type must be one of the following values: google, github, microsoft, spotify, facebook, discord, gitlab, bitbucket, linkedin, apple, x, twitch", + "body": "auth.oauth.providers.invalid.type must be one of the following values: google, github, microsoft, spotify, facebook, discord, gitlab, bitbucket, linkedin, apple, x, twitch, custom_oidc", "headers": Headers {