feat: enhance config agent run tracking and GitHub integration

- Added a new `ConfigAgentRun` model to track the state of configuration agent runs in the database.
- Updated the Prisma schema to include new fields for the `ConfigAgentRun` model, allowing for detailed tracking of run status, timestamps, and associated metadata.
- Introduced new API routes for starting, cancelling, and committing configuration agent runs, improving user interaction and feedback during updates.
- Updated existing routes to utilize the new `run_id` for better tracking and management of agent runs.
- Added a new dependency `diff` to facilitate change tracking in configuration files.

These changes aim to improve the overall functionality and user experience of the configuration agent integration with GitHub.
This commit is contained in:
mantrakp04 2026-06-26 17:22:24 -07:00
parent 47319f221d
commit 64b885fb70
28 changed files with 1936 additions and 1573 deletions

View File

@ -96,6 +96,7 @@
"bcrypt": "^6.0.0",
"cel-js": "^0.8.2",
"chokidar-cli": "^3.0.0",
"diff": "^8.0.3",
"dotenv": "^16.4.5",
"dotenv-cli": "^7.3.0",
"elkjs": "^0.11.1",

View File

@ -1,2 +0,0 @@
ALTER TABLE "BranchConfigOverride"
ADD COLUMN "configAgentRun" JSONB;

View File

@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "ConfigAgentRun" (
"id" UUID NOT NULL,
"projectId" TEXT NOT NULL,
"branchId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"status" TEXT NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL,
"finishedAt" TIMESTAMP(3),
"commitUrl" TEXT,
"error" TEXT,
"sandboxId" TEXT,
"progress" TEXT,
"stage" TEXT,
"diff" TEXT,
"baseCommitSha" TEXT,
CONSTRAINT "ConfigAgentRun_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ConfigAgentRun_projectId_branchId_idx" ON "ConfigAgentRun"("projectId", "branchId");
-- AddForeignKey
ALTER TABLE "ConfigAgentRun" ADD CONSTRAINT "ConfigAgentRun_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -46,6 +46,7 @@ model Project {
tenancies Tenancy[]
branchConfigOverrides BranchConfigOverride[]
environmentConfigOverrides EnvironmentConfigOverride[]
configAgentRuns ConfigAgentRun[]
localEmulatorProject LocalEmulatorProject?
aiConversations AiConversation[]
@ -138,13 +139,37 @@ model BranchConfigOverride {
config Json
source Json?
pushedConfigError Json?
configAgentRun Json?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@id([projectId, branchId])
}
model ConfigAgentRun {
id String @id @default(uuid()) @db.Uuid
projectId String
branchId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status String
startedAt DateTime
finishedAt DateTime?
commitUrl String?
error String?
sandboxId String?
progress String?
stage String?
diff String? @db.Text
baseCommitSha String?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId, branchId])
}
model EnvironmentConfigOverride {
projectId String
branchId String

View File

@ -8,7 +8,7 @@ import {
setConfigAgentRunAwaitingReview,
startConfigAgentRun,
} from "@/lib/config";
import { applyConfigUpdate, type ConfigAgentInFlightStage, type GithubRepoRef, stopConfigAgentSandbox } from "@/lib/config/repo-agent";
import { applyConfigUpdate, type ConfigAgentInFlightStage, 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";
@ -16,15 +16,17 @@ import { getInvalidConfigReason } from "@hexclave/shared/dist/config/format";
import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import { StatusError, captureError } from "@hexclave/shared/dist/utils/errors";
// Background work (sandbox boot, clone, agent edit, push) continues via waitUntil
// after the immediate response, so allow a long invocation.
// Background work (sandbox boot, clone, agent edit, capture the change set) continues
// via waitUntil after the immediate response, so allow a long invocation.
export const maxDuration = 800;
/**
* Kicks off an AI-agent config write to the linked GitHub repo (writes go through
* the agent in a Vercel Sandbox; reads use jiti). The GitHub token is the user's
* own OAuth token, passed transiently for git pull/push never persisted, never
* placed in the agent's environment. The dashboard polls `agent_run` for progress.
* the agent in a Vercel Sandbox; reads use jiti). The agent edits the repo in the
* sandbox; we then capture the change set and stop the sandbox, leaving the run
* `awaiting_review` the actual commit happens later via `/commit`. The GitHub token
* is the user's own OAuth token, passed transiently for the clone never persisted,
* never placed in the agent's environment. The dashboard polls `agent_run` for progress.
*/
export const POST = createSmartRouteHandler({
metadata: {
@ -49,6 +51,7 @@ export const POST = createSmartRouteHandler({
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
status: yupString().oneOf(["started"]).defined(),
id: yupString().defined(),
}).defined(),
}),
handler: async (req) => {
@ -72,28 +75,27 @@ export const POST = createSmartRouteHandler({
const githubToken = req.body.github_access_token;
const nowMs = Date.now();
// Stamps a fresh `running` marker and returns the source read in the same
// FOR UPDATE txn (so a concurrent re-link can't redirect the push). Runs
// aren't serialized; a concurrent commit is caught at push time.
const startedSource = await startConfigAgentRun({ projectId, branchId, nowMs });
const runStartedAt = nowMs;
// Inserts a fresh `running` run row and returns its id plus the source read in
// the same FOR UPDATE txn (so a concurrent re-link can't redirect the push).
// Runs aren't serialized; many can target this branch at once and a concurrent
// commit is caught by GitHub at push time.
const { source: startedSource, runId } = await startConfigAgentRun({ projectId, branchId, nowMs });
const ref: GithubRepoRef = { owner: startedSource.owner, repo: startedSource.repo, branch: startedSource.branch };
// Fetched fresh per boot; this admin route can't mint GitHub tokens for the
// internal user (would be priv-esc), so we reuse the caller's freshest OAuth token.
const getGithubToken = async () => githubToken;
let runningSandboxId: string | null = null;
// Persist + keep an in-memory copy so the catch path can stop the sandbox
// before marking the run terminal.
// Persist the sandbox id so a concurrent cancel can hard-stop it while the agent
// runs. `applyConfigUpdate` owns the sandbox lifetime and always stops it before
// returning/throwing, so the route doesn't need to track or stop it itself.
const onSandboxId = async (sandboxId: string) => {
runningSandboxId = sandboxId;
await recordConfigAgentRunSandbox({ projectId, branchId, runStartedAt, sandboxId });
await recordConfigAgentRunSandbox({ runId, sandboxId });
};
const onProgress = async (activity: string) => {
await recordConfigAgentRunProgress({ projectId, branchId, runStartedAt, progress: activity });
await recordConfigAgentRunProgress({ runId, progress: activity });
};
const onStage = async (stage: ConfigAgentInFlightStage) => {
await recordConfigAgentRunStage({ projectId, branchId, runStartedAt, stage });
await recordConfigAgentRunStage({ runId, stage });
};
runAsynchronouslyAndWaitUntil(async () => {
@ -115,33 +117,28 @@ export const POST = createSmartRouteHandler({
await recordConfigAgentRunResult({
projectId,
branchId,
runStartedAt,
runId,
nowMs: Date.now(),
outcome: { status: "no-change" },
});
} else {
await setConfigAgentRunAwaitingReview({
projectId,
branchId,
runStartedAt,
diff: result.diff,
runId,
change: result.change,
});
}
} catch (error) {
captureError("config-github-apply", error);
if (runningSandboxId != null) {
await stopConfigAgentSandbox(runningSandboxId).catch((e) => captureError("config-github-apply-stop-sandbox", e));
}
await recordConfigAgentRunResult({
projectId,
branchId,
runStartedAt,
runId,
nowMs: Date.now(),
outcome: { status: "error", error: "The config agent failed to apply the change." },
}).catch((e) => captureError("config-github-apply-record-error", e));
}
});
return { statusCode: 200, bodyType: "json", body: { status: "started" } };
return { statusCode: 200, bodyType: "json", body: { status: "started", id: runId } };
},
});

View File

@ -12,9 +12,10 @@ export const maxDuration = 60;
/**
* Atomically flips the run to terminal `cancelled` (so the original run's late
* result is ignored) and hard-stops its sandbox if one was recorded. No revert:
* any commit the agent already pushed stays and is reconciled by the repo's
* stack-auth-config-sync workflow.
* result is ignored) and hard-stops its sandbox if one was recorded (only `running`
* runs have a live sandbox; an `awaiting_review` run's sandbox is already gone, so
* cancelling it just discards the captured change set before it is committed). No
* commit has been pushed at this point, so there is nothing to revert.
*/
export const POST = createSmartRouteHandler({
metadata: {
@ -28,6 +29,9 @@ export const POST = createSmartRouteHandler({
type: adminAuthTypeSchema,
tenancy: adaptSchema,
}).defined(),
body: yupObject({
run_id: yupString().defined(),
}).defined(),
method: yupString().oneOf(["POST"]).defined(),
}),
response: yupObject({
@ -43,15 +47,18 @@ export const POST = createSmartRouteHandler({
await getGithubConfigSourceOrThrow({ projectId, branchId });
const { cancelled, sandboxId } = await cancelConfigAgentRun({ projectId, branchId, nowMs: Date.now() });
const { cancelled, sandboxId, previousStatus } = await cancelConfigAgentRun({ projectId, branchId, runId: req.body.run_id, nowMs: Date.now() });
if (!cancelled) {
return { statusCode: 200, bodyType: "json", body: { status: "not-running" } };
}
if (sandboxId) {
runAsynchronouslyAndWaitUntil(stopConfigAgentSandbox(sandboxId));
} else {
captureError("config-github-cancel", new Error("Cancelled a config agent run but no sandboxId was recorded; the sandbox may still be running."));
} else if (previousStatus === "running") {
// A `running` run should always have a sandbox recorded; missing one means it
// may still be running. (An `awaiting_review` run has no live sandbox — the
// change set was already captured and the sandbox stopped — so that's expected.)
captureError("config-github-cancel", new Error("Cancelled a running config agent run but no sandboxId was recorded; the sandbox may still be running."));
}
return { statusCode: 200, bodyType: "json", body: { status: "cancelling" } };

View File

@ -1,27 +1,29 @@
import {
getConfigAgentRun,
getConfigAgentRunChange,
getGithubConfigSourceOrThrow,
recordConfigAgentRunResult,
} from "@/lib/config";
import { CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR, ConfigRepoCommitConflictError, commitConfigUpdate, type GithubRepoRef, stopConfigAgentSandbox } from "@/lib/config/repo-agent";
import { CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR, ConfigRepoCommitConflictError, commitConfigUpdate, 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 { 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.
// The commit is a handful of GitHub API calls (~seconds); the generous ceiling just
// absorbs slow GitHub responses for large change sets.
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.
* Commits the agent's captured change set to GitHub after the user reviews the diff.
* The change set was captured and persisted when the run entered `awaiting_review`
* (the sandbox is long gone), so this replays it via the GitHub git data API it
* works no matter how long the review took. Returns immediately and does the work in
* the background; the dashboard polls `agent_run` for the result.
*/
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.",
description: "Commits the agent's captured config change to the linked GitHub branch after user review.",
tags: ["Config"],
hidden: true,
},
@ -31,6 +33,7 @@ export const POST = createSmartRouteHandler({
tenancy: adaptSchema,
}).defined(),
body: yupObject({
run_id: yupString().defined(),
github_access_token: yupString().defined(),
commit_message: yupString().optional(),
}).defined(),
@ -40,7 +43,7 @@ export const POST = createSmartRouteHandler({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
status: yupString().oneOf(["committing", "not-awaiting-review", "sandbox-expired"]).defined(),
status: yupString().oneOf(["committing", "not-awaiting-review"]).defined(),
}).defined(),
}),
handler: async (req) => {
@ -49,25 +52,26 @@ export const POST = createSmartRouteHandler({
const source = await getGithubConfigSourceOrThrow({ projectId, branchId });
const run = await getConfigAgentRun({ projectId, branchId });
if (!run || run.status !== "awaiting_review") {
const runId = req.body.run_id;
const plan = await getConfigAgentRunChange({ projectId, branchId, runId });
if (!plan || plan.status !== "awaiting_review") {
return { statusCode: 200, bodyType: "json", body: { status: "not-awaiting-review" } };
}
const runStartedAt = run.started_at;
const sandboxId = run.sandbox_id;
if (!sandboxId) {
// Sandbox id was never recorded — can't commit. Treat as an error and clean up.
if (!plan.change) {
// Awaiting review but no captured change (should not happen for runs created by
// the current apply flow). Mark it errored so the dashboard surfaces a retry.
await recordConfigAgentRunResult({
projectId,
branchId,
runStartedAt,
runId,
nowMs: Date.now(),
outcome: { status: "error", error: "Sandbox session expired. Please retry the update." },
outcome: { status: "error", error: "Failed to commit and push the config changes." },
});
return { statusCode: 200, bodyType: "json", body: { status: "sandbox-expired" } };
return { statusCode: 200, bodyType: "json", body: { status: "not-awaiting-review" } };
}
const change = plan.change;
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 };
@ -75,11 +79,11 @@ export const POST = createSmartRouteHandler({
runAsynchronouslyAndWaitUntil(async () => {
try {
const result = await commitConfigUpdate({ sandboxId, getGithubToken, ref, commitMessage });
const result = await commitConfigUpdate({ getGithubToken, ref, commitMessage, change });
await recordConfigAgentRunResult({
projectId,
branchId,
runStartedAt,
runId,
nowMs: Date.now(),
outcome: { status: "success", commitUrl: result.commitUrl, newCommitHash: result.commitSha },
});
@ -87,11 +91,10 @@ export const POST = createSmartRouteHandler({
if (!(error instanceof ConfigRepoCommitConflictError)) {
captureError("config-github-commit", error);
}
await stopConfigAgentSandbox(sandboxId);
await recordConfigAgentRunResult({
projectId,
branchId,
runStartedAt,
runId,
nowMs: Date.now(),
outcome: {
status: "error",

View File

@ -3,15 +3,15 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, adminAuthTypeSchema, configAgentRunSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
/**
* Returns the state of the most recent dashboardGitHub config agent run (or
* `null`). The dashboard polls this while a run is in flight to show progress and,
* once `awaiting_review`, the diff. Run state lives in its own DB column, separate
* from the config source descriptor.
* Returns the state of a specific dashboardGitHub config agent run (by `run_id`),
* or `null` if it doesn't belong to this branch. The dashboard polls this while a
* run is in flight to show progress and, once `awaiting_review`, the diff. Each run
* is its own row in the `ConfigAgentRun` table, addressed by id.
*/
export const GET = createSmartRouteHandler({
metadata: {
summary: "Get config agent run state",
description: "Returns the in-flight or most recent config agent run for the linked GitHub repo.",
description: "Returns a specific config agent run (by run_id) for the linked GitHub repo.",
tags: ["Config"],
hidden: true,
},
@ -20,6 +20,9 @@ export const GET = createSmartRouteHandler({
type: adminAuthTypeSchema,
tenancy: adaptSchema,
}).defined(),
query: yupObject({
run_id: yupString().defined(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
@ -32,6 +35,7 @@ export const GET = createSmartRouteHandler({
const agentRun = await getConfigAgentRun({
projectId: req.auth.tenancy.project.id,
branchId: req.auth.tenancy.branchId,
runId: req.query.run_id,
});
return {

View File

@ -2,7 +2,7 @@ import { randomUUID } from "crypto";
import { afterEach, describe, expect, it } from "vitest";
import type { Prisma } from "@/generated/prisma/client";
import { globalPrismaClient } from "@/prisma-client";
import { recordConfigAgentRunResult, setConfigAgentRunAwaitingReview, startConfigAgentRun } from "./index";
import { cancelConfigAgentRun, getConfigAgentRun, getConfigAgentRunChange, recordConfigAgentRunResult, recordConfigAgentRunSandbox, setConfigAgentRunAwaitingReview, startConfigAgentRun } from "./index";
const createdProjectIds: string[] = [];
@ -21,7 +21,7 @@ const githubSource: Prisma.InputJsonObject = {
config_file_path: "hexclave.config.ts",
};
async function createBranchConfigOverride(configAgentRun: Prisma.InputJsonObject) {
async function createGithubLinkedBranch() {
const projectId = randomUUID();
const branchId = "main";
createdProjectIds.push(projectId);
@ -34,81 +34,120 @@ async function createBranchConfigOverride(configAgentRun: Prisma.InputJsonObject
},
});
await globalPrismaClient.branchConfigOverride.create({
data: { projectId, branchId, config: {}, source: githubSource, configAgentRun },
data: { projectId, branchId, config: {}, source: githubSource },
});
return { projectId, branchId };
}
async function readRow(projectId: string, branchId: string) {
async function readBranchRow(projectId: string, branchId: string) {
return await globalPrismaClient.branchConfigOverride.findUniqueOrThrow({
where: { projectId_branchId: { projectId, branchId } },
});
}
async function readRun(projectId: string, branchId: string): Promise<JsonRecord> {
const { configAgentRun } = await readRow(projectId, branchId);
if (!isRecord(configAgentRun)) {
throw new Error("Expected branch configAgentRun to be a JSON object.");
}
return configAgentRun;
}
afterEach(async () => {
// ConfigAgentRun rows cascade-delete with the project.
await globalPrismaClient.project.deleteMany({
where: { id: { in: createdProjectIds.splice(0) } },
});
});
describe("config agent run state", () => {
it("overwrites an in-flight run instead of locking it out", async () => {
const { projectId, branchId } = await createBranchConfigOverride({
status: "awaiting_review",
started_at: 1000,
sandbox_id: "sandbox-awaiting-review",
diff: "diff --git a/hexclave.config.ts b/hexclave.config.ts",
});
it("starts independent runs for the same branch instead of overwriting", async () => {
const { projectId, branchId } = await createGithubLinkedBranch();
const source = await startConfigAgentRun({ projectId, branchId, nowMs: 2000 });
const first = await startConfigAgentRun({ projectId, branchId, nowMs: 1000 });
const second = await startConfigAgentRun({ projectId, branchId, nowMs: 2000 });
expect(source.type).toBe("pushed-from-github");
// A fresh `running` marker replaces the prior run — no awaiting_review leftovers.
const run = await readRun(projectId, branchId);
expect(run).toMatchObject({ status: "running", started_at: 2000 });
expect(run.sandbox_id).toBeUndefined();
expect(run.diff).toBeUndefined();
expect(first.source.type).toBe("pushed-from-github");
expect(first.runId).not.toBe(second.runId);
// Runs are NOT serialized: both rows coexist, each still "running".
expect((await getConfigAgentRun({ projectId, branchId, runId: first.runId }))?.status).toBe("running");
expect((await getConfigAgentRun({ projectId, branchId, runId: second.runId }))?.status).toBe("running");
});
it("ignores stale awaiting_review transitions from an older run", async () => {
const { projectId, branchId } = await createBranchConfigOverride({
status: "running",
started_at: 2000,
sandbox_id: "newer-sandbox",
});
it("scopes a run read to its own project/branch", async () => {
const a = await createGithubLinkedBranch();
const b = await createGithubLinkedBranch();
const { runId } = await startConfigAgentRun({ projectId: a.projectId, branchId: a.branchId, nowMs: 1000 });
const result = await setConfigAgentRunAwaitingReview({
projectId,
branchId,
runStartedAt: 1000,
diff: "old diff",
});
expect(result.sandboxId).toBeUndefined();
const run = await readRun(projectId, branchId);
expect(run).toMatchObject({ status: "running", started_at: 2000, sandbox_id: "newer-sandbox" });
expect(run.diff).toBeUndefined();
// The run id is real, but asking under a different project must not leak it.
expect(await getConfigAgentRun({ projectId: b.projectId, branchId: b.branchId, runId })).toBeNull();
expect((await getConfigAgentRun({ projectId: a.projectId, branchId: a.branchId, runId }))?.id).toBe(runId);
});
it("ignores stale terminal writes from an older run", async () => {
const { projectId, branchId } = await createBranchConfigOverride({
status: "running",
started_at: 2000,
sandbox_id: "newer-sandbox",
});
it("won't move a cancelled run to awaiting_review", async () => {
const { projectId, branchId } = await createGithubLinkedBranch();
const { runId } = await startConfigAgentRun({ projectId, branchId, nowMs: 1000 });
await recordConfigAgentRunSandbox({ runId, sandboxId: "sandbox-1" });
const cancel = await cancelConfigAgentRun({ projectId, branchId, runId, nowMs: 2000 });
expect(cancel).toMatchObject({ cancelled: true, sandboxId: "sandbox-1", previousStatus: "running" });
// A late transition from the agent must not resurrect the cancelled run.
await setConfigAgentRunAwaitingReview({ runId, change: { diff: "old diff", baseSha: "abc123" } });
const run = await getConfigAgentRun({ projectId, branchId, runId });
expect(run?.status).toBe("cancelled");
expect(run?.diff).toBeUndefined();
});
it("captures the diff + base commit on awaiting_review; base commit is server-only", async () => {
const { projectId, branchId } = await createGithubLinkedBranch();
const { runId } = await startConfigAgentRun({ projectId, branchId, nowMs: 1000 });
const change = { diff: "diff --git a/hexclave.config.ts b/hexclave.config.ts\n@@ -1 +1 @@\n-a\n+b\n", baseSha: "abc123" };
await setConfigAgentRunAwaitingReview({ runId, change });
// The captured change is readable for the commit route...
const plan = await getConfigAgentRunChange({ projectId, branchId, runId });
expect(plan?.status).toBe("awaiting_review");
expect(plan?.change).toEqual(change);
// ...the dashboard sees the diff but never the base commit (and sandbox id is cleared).
const run = await getConfigAgentRun({ projectId, branchId, runId });
expect(run?.status).toBe("awaiting_review");
expect(run?.diff).toBe(change.diff);
expect(run?.sandbox_id).toBeUndefined();
expect(run as Record<string, unknown>).not.toHaveProperty("base_commit_sha");
expect(run as Record<string, unknown>).not.toHaveProperty("baseCommitSha");
// The captured change scopes to its own project/branch like the run read does.
const other = await createGithubLinkedBranch();
expect(await getConfigAgentRunChange({ projectId: other.projectId, branchId: other.branchId, runId })).toBeNull();
});
it("advances the source commit hash on a successful result", async () => {
const { projectId, branchId } = await createGithubLinkedBranch();
const { runId } = await startConfigAgentRun({ projectId, branchId, nowMs: 1000 });
await setConfigAgentRunAwaitingReview({ runId, change: { diff: "diff --git a/hexclave.config.ts b/hexclave.config.ts", baseSha: "abc123" } });
await recordConfigAgentRunResult({
projectId,
branchId,
runStartedAt: 1000,
runId,
nowMs: 3000,
outcome: {
status: "success",
commitUrl: "https://github.com/hexclave-validation/config-agent-validation/commit/new",
newCommitHash: "new-commit",
},
});
expect((await getConfigAgentRun({ projectId, branchId, runId }))?.status).toBe("success");
const { source } = await readBranchRow(projectId, branchId);
expect(isRecord(source) ? source.commit_hash : null).toBe("new-commit");
});
it("ignores a terminal result for an already-cancelled run", async () => {
const { projectId, branchId } = await createGithubLinkedBranch();
const { runId } = await startConfigAgentRun({ projectId, branchId, nowMs: 1000 });
await cancelConfigAgentRun({ projectId, branchId, runId, nowMs: 2000 });
await recordConfigAgentRunResult({
projectId,
branchId,
runId,
nowMs: 3000,
outcome: {
status: "success",
@ -117,10 +156,9 @@ describe("config agent run state", () => {
},
});
const run = await readRun(projectId, branchId);
expect(run).toMatchObject({ status: "running", started_at: 2000, sandbox_id: "newer-sandbox" });
// The superseded run must not have advanced the source commit hash.
const { source } = await readRow(projectId, branchId);
// The cancel wins; neither the run status nor the source commit hash moves.
expect((await getConfigAgentRun({ projectId, branchId, runId }))?.status).toBe("cancelled");
const { source } = await readBranchRow(projectId, branchId);
expect(isRecord(source) ? source.commit_hash : null).toBe("base-commit");
});
});

View File

@ -1,4 +1,5 @@
import { Prisma } from "@/generated/prisma/client";
import type { ConfigAgentRun as ConfigAgentRunRow } 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";
@ -10,11 +11,11 @@ import { filterUndefined, typedEntries } from "@hexclave/shared/dist/utils/objec
import { Result } from "@hexclave/shared/dist/utils/results";
import { deindent, stringCompare } from "@hexclave/shared/dist/utils/strings";
import * as yup from "yup";
import { RawQuery, globalPrismaClient, rawQuery, retryTransaction } from "../../prisma-client";
import { PrismaClientTransaction, RawQuery, globalPrismaClient, rawQuery, retryTransaction } from "../../prisma-client";
import { DEVELOPMENT_ENVIRONMENT_ENV_CONFIG_BLOCKED_MESSAGE, getEnvironmentConfigWriteBlockReason, isDevelopmentEnvironmentProject } from "../development-environment";
import { getLocalEmulatorFilePath, isLocalEmulatorEnabled, isLocalEmulatorProject, readConfigFromFile, writeConfigToFile } from "../local-emulator";
import { listPermissionDefinitionsFromConfig } from "../permissions";
import type { ConfigAgentInFlightStage } from "./repo-agent";
import type { CapturedChange, ConfigAgentInFlightStage } from "./repo-agent";
type BranchConfigSourceApi = yup.InferType<typeof branchConfigSourceSchema>;
export type BranchConfigPushedError = {
@ -494,42 +495,32 @@ export async function getGithubConfigSourceOrThrow(options: {
return source;
}
/** Maps a `ConfigAgentRun` table row to the API shape the dashboard polls. */
function toConfigAgentRunApi(row: ConfigAgentRunRow): ConfigAgentRunApi {
return {
id: row.id,
status: row.status as ConfigAgentRunApi["status"],
started_at: row.startedAt.getTime(),
...(row.finishedAt != null ? { finished_at: row.finishedAt.getTime() } : {}),
...(row.commitUrl != null ? { commit_url: row.commitUrl } : {}),
...(row.error != null ? { error: row.error as ConfigAgentSafeErrorMessage } : {}),
...(row.sandboxId != null ? { sandbox_id: row.sandboxId } : {}),
...(row.progress != null ? { progress: row.progress } : {}),
...(row.stage != null ? { stage: row.stage as NonNullable<ConfigAgentRunApi["stage"]> } : {}),
...(row.diff != null ? { diff: row.diff } : {}),
};
}
/**
* `FOR UPDATE` read-modify-write on a branch's config-agent run, which lives in its
* own `configAgentRun` column (separate from the immutable `source` descriptor).
* Loads the locked run plus the GitHub source (for the rare mutator that needs the
* repo ref), hands them to `mutate`, writes back whichever of `nextRun` / `nextSource`
* it returns, and returns `mutate`'s `result`. Every run transition goes through here,
* so the locked read and the Prisma JSON `as any` casts live in exactly one place.
* `FOR UPDATE`-locks a single run row by id (so a read-modify-write transition can't
* race a concurrent cancel/commit on the SAME run). Returns `null` if the run is gone.
* Each run is its own row, so this never blocks a different run on the same branch.
*/
async function mutateConfigAgentRun<T>(
options: { projectId: string, branchId: string },
mutate: (run: ConfigAgentRunApi | null, source: GithubConfigSource | null) => {
nextRun?: ConfigAgentRunApi,
nextSource?: GithubConfigSource,
result: T,
},
): Promise<T> {
return await retryTransaction(globalPrismaClient, async (tx) => {
const rows = await tx.$queryRaw<{ source: BranchConfigSourceApi | null, configAgentRun: ConfigAgentRunApi | null }[]>`
SELECT "source", "configAgentRun" FROM "BranchConfigOverride"
WHERE "projectId" = ${options.projectId} AND "branchId" = ${options.branchId}
FOR UPDATE
`;
const rawSource = rows[0]?.source ?? null;
const source = rawSource?.type === "pushed-from-github" ? rawSource : null;
const { nextRun, nextSource, result } = mutate(rows[0]?.configAgentRun ?? null, source);
if (nextRun !== undefined || nextSource !== undefined) {
await tx.branchConfigOverride.update({
where: { projectId_branchId: { projectId: options.projectId, branchId: options.branchId } },
data: {
...(nextRun !== undefined ? { configAgentRun: nextRun as any } : {}),
...(nextSource !== undefined ? { source: nextSource as any } : {}),
},
});
}
return result;
});
async function lockConfigAgentRun(tx: PrismaClientTransaction, runId: string): Promise<ConfigAgentRunRow | null> {
const rows = await tx.$queryRaw<ConfigAgentRunRow[]>`
SELECT * FROM "ConfigAgentRun" WHERE "id" = ${runId}::uuid FOR UPDATE
`;
return rows[0] ?? null;
}
/**
@ -556,59 +547,71 @@ export async function getCompleteBranchConfigForFile(options: {
}
/**
* Records the start of a dashboardGitHub config agent run: stamps a fresh
* `running` marker in the branch's `configAgentRun` column and returns the locked
* GitHub source (for the repo ref). Runs are intentionally NOT serialized a new
* run overwrites any prior marker, the recorders below ignore writes whose
* `started_at` no longer matches (so a superseded run's late callbacks no-op), and
* a concurrent edit to the real repo is caught at push time
* (`assertRemoteBranchStillAtClonedHead` / non-fast-forward `ConfigRepoCommitConflictError`).
* {@link recordConfigAgentRunResult} writes the terminal status when it finishes.
* Records the start of a dashboardGitHub config agent run: inserts a fresh
* `running` row in the `ConfigAgentRun` table and returns its id plus the locked
* GitHub source (for the repo ref). Runs are intentionally NOT serialized each
* start is an independent row, so many runs can target the same branch at once;
* a concurrent edit to the real repo is caught by GitHub at push time
* (`assertRemoteBranchStillAtClonedHead` / non-fast-forward `ConfigRepoCommitConflictError`),
* never by a DB lock. {@link recordConfigAgentRunResult} writes the terminal status.
*/
export async function startConfigAgentRun(options: {
projectId: string,
branchId: string,
nowMs: number,
}): Promise<GithubConfigSource> {
return await mutateConfigAgentRun(options, (_run, source) => {
if (!source) {
}): Promise<{ source: GithubConfigSource, runId: string }> {
return await retryTransaction(globalPrismaClient, async (tx) => {
// Read (and lock) the source in the same txn so a concurrent re-link can't
// redirect this run's push to a different repo.
const rows = await tx.$queryRaw<{ source: BranchConfigSourceApi | null }[]>`
SELECT "source" FROM "BranchConfigOverride"
WHERE "projectId" = ${options.projectId} AND "branchId" = ${options.branchId}
FOR UPDATE
`;
const source = rows[0]?.source ?? null;
if (source?.type !== "pushed-from-github") {
throw new HexclaveAssertionError("Config source is not linked to GitHub; cannot run the config agent.");
}
return { nextRun: { status: "running", started_at: options.nowMs }, result: source };
const run = await tx.configAgentRun.create({
data: {
projectId: options.projectId,
branchId: options.branchId,
status: "running",
startedAt: new Date(options.nowMs),
},
select: { id: true },
});
return { source, runId: run.id };
});
}
/** True when `run` is the still-`running` run identified by `startedAt`. */
function isRunningRun(run: ConfigAgentRunApi | null, startedAt: number): run is ConfigAgentRunApi {
return run?.status === "running" && run.started_at === startedAt;
}
/** Reads the current config-agent run state (or `null`) for the dashboard to poll. */
/**
* Reads a specific config-agent run (scoped to its project/branch) for the
* dashboard to poll. Returns `null` if the run id doesn't belong to this branch.
*/
export async function getConfigAgentRun(options: {
projectId: string,
branchId: string,
runId: string,
}): Promise<ConfigAgentRunApi | null> {
const row = await globalPrismaClient.branchConfigOverride.findUnique({
where: { projectId_branchId: { projectId: options.projectId, branchId: options.branchId } },
select: { configAgentRun: true },
const row = await globalPrismaClient.configAgentRun.findFirst({
where: { id: options.runId, projectId: options.projectId, branchId: options.branchId },
});
return (row?.configAgentRun ?? null) as ConfigAgentRunApi | null;
return row ? toConfigAgentRunApi(row) : null;
}
/**
* Records the live sandbox id of an in-flight run so a later cancel (a separate
* request/invocation) can hard-stop it. No-ops unless this run is still `running`,
* so a late write can't resurrect a sandbox id onto a terminal/superseded run.
* request/invocation) can hard-stop it. The `status = "running"` guard makes it a
* no-op once the run is terminal, so a late write can't resurrect a sandbox id.
*/
export async function recordConfigAgentRunSandbox(options: {
projectId: string,
branchId: string,
runStartedAt: number,
runId: string,
sandboxId: string,
}): Promise<void> {
await mutateConfigAgentRun(options, (run) => {
if (!isRunningRun(run, options.runStartedAt)) return { result: undefined };
return { nextRun: { ...run, sandbox_id: options.sandboxId }, result: undefined };
await globalPrismaClient.configAgentRun.updateMany({
where: { id: options.runId, status: "running" },
data: { sandboxId: options.sandboxId },
});
}
@ -618,16 +621,14 @@ export async function recordConfigAgentRunSandbox(options: {
* caller is responsible for keeping `progress` short and free of secrets/tokens.
*/
export async function recordConfigAgentRunProgress(options: {
projectId: string,
branchId: string,
runStartedAt: number,
runId: string,
progress: string,
}): Promise<void> {
await mutateConfigAgentRun(options, (run) => {
if (!isRunningRun(run, options.runStartedAt)) return { result: undefined };
await globalPrismaClient.configAgentRun.updateMany({
where: { id: options.runId, status: "running" },
// DB-size guard on the persisted feed; the runner already trims lines/count
// (see buildRunnerScript), so this only bites pathological input.
return { nextRun: { ...run, progress: options.progress.slice(0, 2000) }, result: undefined };
data: { progress: options.progress.slice(0, 2000) },
});
}
@ -636,90 +637,149 @@ export async function recordConfigAgentRunProgress(options: {
* No-ops unless this run is still `running`.
*/
export async function recordConfigAgentRunStage(options: {
projectId: string,
branchId: string,
runStartedAt: number,
runId: string,
stage: ConfigAgentInFlightStage,
}): Promise<void> {
await mutateConfigAgentRun(options, (run) => {
if (!isRunningRun(run, options.runStartedAt)) return { result: undefined };
return { nextRun: { ...run, stage: options.stage }, result: undefined };
await globalPrismaClient.configAgentRun.updateMany({
where: { id: options.runId, status: "running" },
data: { stage: options.stage },
});
}
/**
* Transitions a `running` run to `awaiting_review`: the agent has finished editing
* but has not yet committed. The diff is stored for the dashboard, and the
* sandbox_id is returned (the sandbox stays alive to commit+push on confirm, or is
* hard-stopped on cancel).
* Transitions a `running` run to `awaiting_review`: the agent has finished editing and
* the change is already captured (the sandbox has been stopped). The diff is stored for
* the dashboard AND as the commit source, with `baseCommitSha` (the commit it was made
* against) so it can be rebuilt + pushed via the GitHub API on confirm; the stale
* sandbox id is cleared. No-ops if the run is no longer `running` e.g. cancelled mid-flight.
*/
export async function setConfigAgentRunAwaitingReview(options: {
projectId: string,
branchId: string,
runStartedAt: number,
diff: string,
}): Promise<{ sandboxId: string | undefined }> {
return await mutateConfigAgentRun(options, (run) => {
if (!isRunningRun(run, options.runStartedAt)) return { result: { sandboxId: undefined } };
return {
nextRun: { ...run, status: "awaiting_review", stage: "awaiting_review", diff: options.diff.slice(0, 100_000) },
result: { sandboxId: run.sandbox_id },
};
runId: string,
change: CapturedChange,
}): Promise<void> {
await retryTransaction(globalPrismaClient, async (tx) => {
const run = await lockConfigAgentRun(tx, options.runId);
if (run?.status !== "running") return;
await tx.configAgentRun.update({
where: { id: options.runId },
data: {
status: "awaiting_review",
stage: "awaiting_review",
// The diff is authoritative for the commit, so it is stored whole (already
// size-capped at capture time), not truncated.
diff: options.change.diff,
baseCommitSha: options.change.baseSha,
sandboxId: null,
},
});
});
}
/**
* Requests cancellation of the in-flight config agent run. Atomically flips a
* Loads a run's captured change (diff + base commit) for the commit route, scoped to
* its project/branch. Returns `null` if the run id doesn't belong to this branch, or
* `{ status, change: null }` if the row isn't carrying a complete capture.
*/
export async function getConfigAgentRunChange(options: {
projectId: string,
branchId: string,
runId: string,
}): Promise<{ status: string, change: CapturedChange | null } | null> {
const row = await globalPrismaClient.configAgentRun.findFirst({
where: { id: options.runId, projectId: options.projectId, branchId: options.branchId },
select: { status: true, diff: true, baseCommitSha: true },
});
if (!row) return null;
const change = row.diff != null && row.baseCommitSha != null
? { diff: row.diff, baseSha: row.baseCommitSha }
: null;
return { status: row.status, change };
}
/**
* Requests cancellation of a specific config agent run. Atomically flips a
* `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 run is in flight. (No revert: stopping the sandbox
* before the push undoes the change; a commit that already landed stays.)
* the sandbox id (only present while `running`) so the caller can hard-stop the
* sandbox, plus the `previousStatus` (so the caller can tell "running with no sandbox
* recorded" — a real leak — from "awaiting_review", where the sandbox is expected to
* be gone already). Returns `{ cancelled: false }` when the run is gone, not on this
* branch, or already terminal. (No revert: a commit that already landed stays.)
*/
export async function cancelConfigAgentRun(options: {
projectId: string,
branchId: string,
runId: string,
nowMs: number,
}): Promise<{ cancelled: boolean, sandboxId?: string }> {
return await mutateConfigAgentRun<{ cancelled: boolean, sandboxId?: string }>(options, (run) => {
if (!run || (run.status !== "running" && run.status !== "awaiting_review")) return { result: { cancelled: false } };
return {
nextRun: { status: "cancelled", started_at: run.started_at, finished_at: options.nowMs },
result: { cancelled: true, sandboxId: run.sandbox_id },
};
}): Promise<{ cancelled: boolean, sandboxId?: string, previousStatus?: string }> {
return await retryTransaction(globalPrismaClient, async (tx) => {
const run = await lockConfigAgentRun(tx, options.runId);
if (!run || run.projectId !== options.projectId || run.branchId !== options.branchId) {
return { cancelled: false };
}
if (run.status !== "running" && run.status !== "awaiting_review") {
return { cancelled: false };
}
await tx.configAgentRun.update({
where: { id: options.runId },
data: { status: "cancelled", finishedAt: new Date(options.nowMs), sandboxId: null, stage: null, baseCommitSha: null },
});
return { cancelled: true, sandboxId: run.sandboxId ?? undefined, previousStatus: run.status };
});
}
/**
* Records the outcome of a config agent run: stamps the terminal run status, and on
* a pushed commit advances the source's `commit_hash`. No-ops if a cancel already
* landed or the run was superseded (its `started_at` no longer matches).
* a pushed commit advances the source's `commit_hash`. No-ops unless the run is
* still in flight (`running`/`awaiting_review`), so a cancel that already landed wins.
*/
export async function recordConfigAgentRunResult(options: {
projectId: string,
branchId: string,
runStartedAt: number,
runId: string,
nowMs: number,
outcome:
| { status: "success", commitUrl?: string, newCommitHash?: string }
| { status: "no-change" }
| { status: "error", error: ConfigAgentSafeErrorMessage },
}): Promise<void> {
await mutateConfigAgentRun(options, (run, source) => {
if (run?.started_at !== options.runStartedAt || run.status === "cancelled") return { result: undefined };
const finished = { started_at: options.runStartedAt, finished_at: options.nowMs };
await retryTransaction(globalPrismaClient, async (tx) => {
const run = await lockConfigAgentRun(tx, options.runId);
if (!run || (run.status !== "running" && run.status !== "awaiting_review")) return;
const finishedAt = new Date(options.nowMs);
if (options.outcome.status === "error") {
return { nextRun: { status: "error", ...finished, error: options.outcome.error }, result: undefined };
await tx.configAgentRun.update({
where: { id: options.runId },
data: { status: "error", finishedAt, error: options.outcome.error, sandboxId: null, stage: null, baseCommitSha: null },
});
return;
}
if (options.outcome.status === "no-change") {
return { nextRun: { status: "no-change", ...finished }, result: undefined };
await tx.configAgentRun.update({
where: { id: options.runId },
data: { status: "no-change", finishedAt, sandboxId: null, stage: null, baseCommitSha: null },
});
return;
}
await tx.configAgentRun.update({
where: { id: options.runId },
data: { status: "success", finishedAt, commitUrl: options.outcome.commitUrl ?? null, sandboxId: null, stage: null, baseCommitSha: null },
});
// Advance the source's last-known commit when a commit landed and the branch
// is still GitHub-linked (locked in the same txn).
if (options.outcome.newCommitHash) {
const sourceRows = await tx.$queryRaw<{ source: BranchConfigSourceApi | null }[]>`
SELECT "source" FROM "BranchConfigOverride"
WHERE "projectId" = ${options.projectId} AND "branchId" = ${options.branchId}
FOR UPDATE
`;
const source = sourceRows[0]?.source ?? null;
if (source?.type === "pushed-from-github") {
await tx.branchConfigOverride.update({
where: { projectId_branchId: { projectId: options.projectId, branchId: options.branchId } },
data: { source: { ...source, commit_hash: options.outcome.newCommitHash } as any },
});
}
}
return {
nextRun: { status: "success", ...finished, commit_url: options.outcome.commitUrl },
// Advance the source's last-known commit when a commit landed and the branch
// is still GitHub-linked.
nextSource: source && options.outcome.newCommitHash ? { ...source, commit_hash: options.outcome.newCommitHash } : undefined,
result: undefined,
};
});
}

View File

@ -1,29 +1,10 @@
import { describe, expect, it } from "vitest";
import { CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR, ConfigRepoCommitConflictError, isGitBranchConflictOutput } from "./repo-agent";
describe("config repo agent commit conflict detection", () => {
it("detects GitHub non-fast-forward push rejection output", () => {
expect(isGitBranchConflictOutput(`
To https://github.com/acme/app.git
! [rejected] HEAD -> main (non-fast-forward)
error: failed to push some refs to 'https://github.com/acme/app.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes before pushing again.
`)).toMatchInlineSnapshot(`true`);
});
it("detects stale-info push rejection output", () => {
expect(isGitBranchConflictOutput(`
! [rejected] HEAD -> feature/config (stale info)
error: failed to push some refs to 'https://github.com/acme/app.git'
`)).toMatchInlineSnapshot(`true`);
});
it("does not treat unrelated git failures as branch conflicts", () => {
expect(isGitBranchConflictOutput("fatal: Authentication failed for 'https://github.com/acme/app.git/'")).toMatchInlineSnapshot(`false`);
});
import { CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR, ConfigRepoCommitConflictError, rebuildFilesFromDiff } from "./repo-agent";
describe("config repo agent commit conflict", () => {
it("uses a safe user-facing conflict message", () => {
// A concurrent push surfaces as this error (the pre-check mismatch or a 422 from
// the non-forced GitHub ref update); the message must stay in the safe allowlist.
expect(new ConfigRepoCommitConflictError().message).toMatchInlineSnapshot(
`"The GitHub branch changed before the config commit could be pushed. Retry the update to apply the same changes on the latest branch."`,
);
@ -32,3 +13,58 @@ describe("config repo agent commit conflict detection", () => {
);
});
});
describe("rebuildFilesFromDiff", () => {
// git diff for a modified file (the config), a brand-new imported file, and a deletion —
// the kind of multi-file change the agent produces when the config pulls in other files.
const base = new Map<string, string>([
["hexclave.config.ts", "import { theme } from \"./theme\";\nexport default { theme, signUp: false };\n"],
["legacy.ts", "export const legacy = true;\n"],
]);
const resolveBase = async (path: string) => base.get(path) ?? "";
const diff = [
"diff --git a/hexclave.config.ts b/hexclave.config.ts",
"index 1111111..2222222 100644",
"--- a/hexclave.config.ts",
"+++ b/hexclave.config.ts",
"@@ -1,2 +1,2 @@",
" import { theme } from \"./theme\";",
"-export default { theme, signUp: false };",
"+export default { theme, signUp: true };",
"diff --git a/theme.ts b/theme.ts",
"new file mode 100644",
"index 0000000..3333333",
"--- /dev/null",
"+++ b/theme.ts",
"@@ -0,0 +1 @@",
"+export const theme = \"dark\";",
"diff --git a/legacy.ts b/legacy.ts",
"deleted file mode 100644",
"index 4444444..0000000",
"--- a/legacy.ts",
"+++ /dev/null",
"@@ -1 +0,0 @@",
"-export const legacy = true;",
"",
].join("\n");
it("rebuilds a modified file, a new file, and a deletion across the whole repo", async () => {
const files = await rebuildFilesFromDiff(diff, resolveBase);
expect(files).toContainEqual({
path: "hexclave.config.ts",
newContent: "import { theme } from \"./theme\";\nexport default { theme, signUp: true };\n",
});
// A new imported file the agent added — applied onto an empty base.
expect(files).toContainEqual({ path: "theme.ts", newContent: "export const theme = \"dark\";\n" });
// A deleted file — recorded as a deletion, no content.
expect(files).toContainEqual({ path: "legacy.ts", deleted: true });
expect(files).toHaveLength(3);
});
it("throws if a hunk cannot be applied onto the given base (stale/corrupt diff)", async () => {
const wrongBase = async () => "totally different contents\n";
await expect(rebuildFilesFromDiff(diff, wrongBase)).rejects.toThrow(/Could not rebuild/);
});
});

View File

@ -8,16 +8,25 @@
* or token; build via scripts/config-agent/build-image.ts -> STACK_CONFIG_AGENT_BASE_SNAPSHOT_ID,
* else cold-boot node24 + install the SDK inline) and take a FRESH shallow clone per write.
*
* Token discipline: a fresh token is fetched per boot (GithubTokenProvider),
* injected only into the git remote URL (never the agent's env), redacted from
* thrown errors, and never snapshotted the sandbox is never snapshotted, so the
* token in `origin` dies with it.
* The sandbox is short-lived: it exists only to run the agent and CAPTURE the change
* as a unified diff plus the base commit it was made against. As soon as that is
* captured the sandbox is stopped the review window is then unbounded and free
* (nothing is kept alive). On confirm, `commitConfigUpdate` rebuilds the file contents
* by applying the stored diff onto the exact base files (fetched from GitHub) and
* commits via the git data REST API, so a slow reviewer, a closed tab, or a crashed
* browser can never strand a running sandbox.
*
* Token discipline: a fresh token is fetched per boot/commit (GithubTokenProvider).
* In the sandbox it is injected only into the clone remote URL (never the agent's env),
* redacted from thrown errors, and never snapshotted. At commit time it is sent only as
* an `Authorization` header to api.github.com, never persisted.
*/
import { buildCompleteConfigAgentPrompt, CONFIG_AGENT_REPO_TOOLS } from "@hexclave/shared-backend/config-agent";
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
import { captureError } from "@hexclave/shared/dist/utils/errors";
import { Sandbox } from "@vercel/sandbox";
import { applyPatch, parsePatch } from "diff";
import { PRODUCTION_AI_PROXY_BASE_URL } from "../ai/proxy-url";
const AGENT_SDK_VERSION = "0.2.73";
@ -26,7 +35,10 @@ const REPO_DIR = `${BASE}/repo`;
const TOOLS_DIR = BASE; // agent SDK + runner live here, separate from the repo
const DEFAULT_AGENT_MODEL = "anthropic/claude-haiku-4.5";
const SANDBOX_TIMEOUT_MS = 900_000;
const REVIEW_SANDBOX_KEEPALIVE_MS = 5 * 60_000;
// Cap on the unified diff we persist (it is authoritative for the deferred commit, so
// it is never truncated — instead an oversized change fails at capture time). Config
// diffs are tiny; this only guards against a pathological agent rewriting the repo.
const MAX_CONFIG_DIFF_BYTES = 1_000_000;
const GIT_BOT_NAME = "Hexclave Config Bot";
const GIT_BOT_EMAIL = "config-bot@hexclave.com";
@ -39,14 +51,20 @@ export type GithubRepoRef = { owner: string, repo: string, branch: string };
export type ConfigAgentInFlightStage = "initializing_sandbox" | "cloning_repo" | "agent_making_changes";
/**
* Supplies a GitHub token at sandbox-boot time. The orchestrator calls it once
* per boot (right before the sandbox first touches the repo) instead of
* capturing a single token for the whole flow, so a long-lived run always picks
* up the freshest token the caller can produce (the dashboard refetches the
* user's OAuth token per request).
* Supplies a GitHub token at the moment it is needed (sandbox boot for clone, or
* commit time for the REST push) instead of capturing a single token for the whole
* flow, so a long-lived run always picks up the freshest token the caller can produce
* (the dashboard refetches the user's OAuth token per request).
*/
export type GithubTokenProvider = () => Promise<string>;
/**
* The lightweight change capture persisted for a deferred commit: the unified diff
* the agent produced and the commit it was made against. The diff doubles as the
* dashboard's review render and the commit source (applied onto the base on confirm).
*/
export type CapturedChange = { diff: string, baseSha: string };
export type ConfigUpdateCommitResult = { mode: "commit-to-branch", branch: string, commitUrl: string, commitSha: string };
export const CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR = "The GitHub branch changed before the config commit could be pushed. Retry the update to apply the same changes on the latest branch.";
@ -64,14 +82,6 @@ export class ConfigRepoCommitConflictError extends ConfigRepoAgentError {
}
}
export function isGitBranchConflictOutput(output: string): boolean {
const normalized = output.toLowerCase();
return normalized.includes("non-fast-forward")
|| normalized.includes("fetch first")
|| normalized.includes("stale info")
|| (normalized.includes("failed to push some refs") && normalized.includes("updates were rejected"));
}
// ---------------------------------------------------------------------------
// Sandbox credentials + low-level command helpers
// ---------------------------------------------------------------------------
@ -104,16 +114,6 @@ async function stopSandboxWithContext(sandboxId: string, context: string): Promi
}
}
async function keepSandboxAliveForReview(sandbox: Sandbox): Promise<void> {
// 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<void> {
if (!onProgress) return;
try {
@ -345,21 +345,14 @@ async function gitHead(sandbox: Sandbox): Promise<string> {
return (await run(sandbox, "git", ["-C", REPO_DIR, "rev-parse", "HEAD"])).stdout.trim();
}
async function assertRemoteBranchStillAtClonedHead(sandbox: Sandbox, githubToken: string, ref: GithubRepoRef): Promise<void> {
const clonedHead = await gitHead(sandbox);
await run(sandbox, "git", [
"-C",
REPO_DIR,
"fetch",
"--depth",
"1",
tokenUrl(githubToken, ref),
`+refs/heads/${ref.branch}:refs/remotes/origin/${ref.branch}`,
]);
const remoteHead = (await run(sandbox, "git", ["-C", REPO_DIR, "rev-parse", `refs/remotes/origin/${ref.branch}`])).stdout.trim();
if (remoteHead !== clonedHead) {
throw new ConfigRepoCommitConflictError();
}
/**
* True if a unified diff contains a binary-file change. We can't reconstruct binary
* edits from a textual diff (git emits a "Binary files … differ" stub, not content),
* so such a change is rejected at capture time. `--no-renames` keeps it to add/modify/
* delete, which `parsePatch` maps cleanly when we rebuild the contents on commit.
*/
function diffHasBinaryChange(diff: string): boolean {
return /^Binary files .* differ$/m.test(diff) || diff.includes("GIT binary patch");
}
/**
@ -436,20 +429,20 @@ export async function buildConfigAgentBaseSnapshot(onProgress?: (msg: string) =>
}
// ---------------------------------------------------------------------------
// Apply update (on save)
// Apply update (on save): run the agent, capture the change set, stop the sandbox
// ---------------------------------------------------------------------------
/**
* 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`.
* The result of `applyConfigUpdate`. On `awaiting_review` the sandbox has ALREADY
* been stopped and the change is captured as a diff + base commit in `change`, so the
* caller only needs to persist it; the commit is rebuilt + pushed later via
* {@link commitConfigUpdate}.
*/
export type ConfigUpdateApplyResult =
| {
mode: "awaiting_review",
sandboxId: string,
/** Unified git diff of the agent's changes; never contains secrets. */
diff: string,
/** The agent's unified diff (review render + commit source) and its base commit. */
change: CapturedChange,
}
| { mode: "no-change" };
@ -466,98 +459,293 @@ export async function applyConfigUpdate(options: {
const step = async (msg: string) => {
await reportConfigAgentProgress(onProgress, msg, "config-repo-agent-step-record");
};
const githubToken = await getGithubToken(); // fresh token for this boot
const githubToken = await getGithubToken(); // fresh token for the clone
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).
await reportConfigAgentStage(onStage, "agent_making_changes");
await step("Agent editing config…");
await runAgent(sandbox, buildCompleteConfigAgentPrompt({
scope: { mode: "repo" },
completeConfig,
commandPolicy: "Do NOT install dependencies, run builds, or run a type check. The repository's own CI validates the change after we push, and dependencies are intentionally not installed in this sandbox.",
}), 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 pre-staging diff for review, sanitized like every other sandbox-exit string.
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<ConfigUpdateCommitResult> {
const { sandboxId, ref, commitMessage } = options;
const githubToken = await options.getGithubToken();
const sandbox = await getConfigAgentSandbox(sandboxId);
try {
await assertRemoteBranchStillAtClonedHead(sandbox, githubToken, ref);
// Record the id so a concurrent cancel (a separate invocation) can hard-stop the
// sandbox while the agent is still running.
await onSandboxId?.(sandbox.sandboxId);
// 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`. We never push from the sandbox, so the token is not needed again.
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).
await reportConfigAgentStage(onStage, "agent_making_changes");
await step("Agent editing config…");
await runAgent(sandbox, buildCompleteConfigAgentPrompt({
scope: { mode: "repo" },
completeConfig,
commandPolicy: "Do NOT install dependencies, run builds, or run a type check. The repository's own CI validates the change after we push, and dependencies are intentionally not installed in this sandbox.",
}), onProgress);
// Stage everything so new/renamed files are captured too, then check for changes.
await run(sandbox, "git", ["-C", REPO_DIR, "add", "-A"]);
await run(sandbox, "git", ["-C", REPO_DIR, "commit", "-m", commitMessage]);
const commitSha = await gitHead(sandbox);
// Re-inject the token for the push only.
try {
await run(sandbox, "git", ["-C", REPO_DIR, "push", tokenUrl(githubToken, ref), `HEAD:refs/heads/${ref.branch}`]);
} catch (error) {
if (isGitBranchConflictOutput(error instanceof Error ? error.message : String(error))) {
throw new ConfigRepoCommitConflictError({ cause: error });
}
throw error;
const dirty = (await runRaw(sandbox, "git", ["-C", REPO_DIR, "status", "--porcelain"])).stdout.trim();
if (dirty === "") {
return { mode: "no-change" };
}
return { mode: "commit-to-branch", branch: ref.branch, commitUrl: `https://github.com/${ref.owner}/${ref.repo}/commit/${commitSha}`, commitSha };
// `add -A` does not move HEAD, so this is still the commit we cloned — the base
// the diff is rebuilt against, and our fast-forward conflict check, at commit time.
const baseSha = await gitHead(sandbox);
// The diff drives BOTH the review render and the commit (`--no-renames` keeps it to
// add/modify/delete; `--cached HEAD` includes newly created files; sanitized for any
// stray token, though config diffs never carry one).
const diff = redactTokens(
(await runRaw(sandbox, "git", ["-c", "core.quotePath=false", "-C", REPO_DIR, "diff", "--cached", "--no-renames", "HEAD"])).stdout,
);
if (diff.trim() === "") {
return { mode: "no-change" };
}
if (diffHasBinaryChange(diff)) {
throw new ConfigRepoAgentError("The config change includes a binary file, which can't be committed from the dashboard.");
}
if (Buffer.byteLength(diff, "utf-8") > MAX_CONFIG_DIFF_BYTES) {
throw new ConfigRepoAgentError("The config change is too large to commit.");
}
return { mode: "awaiting_review", change: { diff, baseSha } };
} finally {
await stopSandboxWithContext(sandboxId, "config-repo-agent-commit-stop");
// The sandbox's whole job is done once the change set is captured; the commit is
// replayed later via the GitHub API, so we never keep it alive for review.
await stopSandboxWithContext(sandbox.sandboxId, "config-repo-agent-apply-stop");
}
}
// ---------------------------------------------------------------------------
// Cancel (hard-stop an in-flight run; no revert)
// Commit (on confirm): rebuild contents from the diff + base, push via the REST API
// ---------------------------------------------------------------------------
const GITHUB_API_BASE = "https://api.github.com";
// Git tree mode for the files we write. The config agent only edits regular text
// files; mode changes aren't carried by the textual diff, so everything is 100644.
const TREE_FILE_MODE = "100644";
type GithubFetchResult = { ok: boolean, status: number, json: any };
/** One reconstructed file ready for the commit tree. Deletions carry no content. */
export type CommitFile = { path: string, newContent: string } | { path: string, deleted: true };
/** Branch ref path segment, encoding each component but keeping `/` literal (refs can be nested). */
function encodeBranchPath(branch: string): string {
return branch.split("/").map(encodeURIComponent).join("/");
}
/** Encode a repo file path for a URL, keeping `/` literal between segments. */
function encodeFilePath(path: string): string {
return path.split("/").map(encodeURIComponent).join("/");
}
/** Authenticated api.github.com request. The token is sent only as a header, never in a URL. */
async function githubFetch(token: string, method: string, path: string, body?: unknown): Promise<GithubFetchResult> {
const res = await fetch(`${GITHUB_API_BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "hexclave-config-agent",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
let json: any = null;
try {
json = await res.json();
} catch {
json = null;
}
return { ok: res.ok, status: res.status, json };
}
/** Like {@link githubFetch} but throws on a non-2xx response. Error messages never include the token. */
async function githubJson(token: string, method: string, path: string, body?: unknown): Promise<any> {
const r = await githubFetch(token, method, path, body);
if (!r.ok) {
const detail = typeof r.json?.message === "string" ? `: ${r.json.message}` : "";
throw new ConfigRepoAgentError(`GitHub API ${method} ${path} failed (${r.status})${detail}`);
}
return r.json;
}
/** Strip git's `a/` / `b/` diff prefix from a filename (and treat `/dev/null` as absent). */
function stripDiffPrefix(name: string | undefined): string | null {
if (!name || name === "/dev/null") return null;
return name.replace(/^[ab]\//, "");
}
/**
* Fetches a file's content at a specific commit from GitHub, as a UTF-8 string. Returns
* "" if the file does not exist at that commit (a freshly added file). Falls back to the
* blob API when the contents API declines to inline a large file.
*/
async function fetchBaseFileContent(token: string, repoPath: string, filePath: string, baseSha: string): Promise<string> {
const r = await githubFetch(token, "GET", `${repoPath}/contents/${encodeFilePath(filePath)}?ref=${baseSha}`);
if (r.status === 404) return "";
if (!r.ok) {
const detail = typeof r.json?.message === "string" ? `: ${r.json.message}` : "";
throw new ConfigRepoAgentError(`GitHub API GET ${repoPath}/contents failed (${r.status})${detail}`);
}
if (r.json?.encoding === "base64" && typeof r.json?.content === "string") {
return Buffer.from(r.json.content, "base64").toString("utf-8");
}
// Large files: the contents API returns no inline content; fetch the blob by sha.
if (typeof r.json?.sha === "string") {
const blob = await githubJson(token, "GET", `${repoPath}/git/blobs/${r.json.sha}`);
if (blob?.encoding === "base64" && typeof blob?.content === "string") {
return Buffer.from(blob.content, "base64").toString("utf-8");
}
}
throw new ConfigRepoAgentError(`Could not read the base content of ${filePath} from GitHub.`);
}
/**
* Rebuilds the changed files by applying a unified diff onto the base content of each
* file, which `getBaseContent` resolves by path (new files apply onto ""). Pure aside
* from that resolver, so it is unit-testable. Because the base matches the diff's
* context lines exactly, `applyPatch` is deterministic. Deletions are recorded as such.
* Throws ( a retryable commit error) if a hunk fails to apply.
*/
export async function rebuildFilesFromDiff(diff: string, getBaseContent: (path: string) => Promise<string>): Promise<CommitFile[]> {
const files: CommitFile[] = [];
for (const patch of parsePatch(diff)) {
const oldPath = stripDiffPrefix(patch.oldFileName);
const newPath = stripDiffPrefix(patch.newFileName);
if (newPath === null) {
// Deletion (newFileName is /dev/null).
if (oldPath !== null) files.push({ path: oldPath, deleted: true });
continue;
}
// Added file: oldPath is /dev/null → base is empty. Otherwise resolve the base.
const base = oldPath === null ? "" : await getBaseContent(oldPath);
const applied = applyPatch(base, patch);
if (applied === false) {
throw new ConfigRepoAgentError(`Could not rebuild ${newPath} from the stored diff.`);
}
files.push({ path: newPath, newContent: applied });
}
return files;
}
/**
* Commits a captured change to the linked branch via GitHub's git data API no
* sandbox required, so it works no matter how long the review took. The change is
* stored only as a diff + base commit; here we rebuild the file contents by applying
* that diff onto the base, then: verify the branch still points at the base (fast-
* forward guard), create a blob per file, build a tree on the base tree (deletions via
* `sha: null`), create the commit, and fast-forward the branch ref. A concurrent push
* surfaces as {@link ConfigRepoCommitConflictError} (the pre-check mismatch or a 422
* from the non-forced ref update). The token is fetched fresh the user may have been
* reviewing for a while.
*/
export async function commitConfigUpdate(options: {
getGithubToken: GithubTokenProvider,
ref: GithubRepoRef,
commitMessage: string,
change: CapturedChange,
}): Promise<ConfigUpdateCommitResult> {
const { ref, commitMessage, change } = options;
const token = await options.getGithubToken();
const repoPath = `/repos/${ref.owner}/${ref.repo}`;
const encodedBranch = encodeBranchPath(ref.branch);
// 1. Fast-forward guard: the branch must still be at the commit we cloned. (The
// non-forced ref update in step 6 guards the remaining race window too.)
const refData = await githubJson(token, "GET", `${repoPath}/git/ref/heads/${encodedBranch}`);
if (refData?.object?.sha !== change.baseSha) {
throw new ConfigRepoCommitConflictError();
}
// 2. Rebuild every file the agent changed from the diff applied onto the exact base
// files. The diff spans the whole repo (config file + any imports/codegen it pulls
// in), so this reproduces the agent's full change, not just the config file.
const files = await rebuildFilesFromDiff(change.diff, (path) => fetchBaseFileContent(token, repoPath, path, change.baseSha));
if (files.length === 0) {
throw new ConfigRepoAgentError("The stored diff produced no file changes to commit.");
}
// 3. Resolve the base tree so we only have to specify changed entries.
const baseCommit = await githubJson(token, "GET", `${repoPath}/git/commits/${change.baseSha}`);
const baseTreeSha = baseCommit?.tree?.sha;
if (typeof baseTreeSha !== "string") {
throw new ConfigRepoAgentError("Could not resolve the base tree for the config commit.");
}
// 4. Create a blob per non-deleted file and build the tree entries.
const treeEntries: Array<{ path: string, mode: string, type: "blob", sha: string | null }> = [];
for (const file of files) {
if ("deleted" in file) {
treeEntries.push({ path: file.path, mode: TREE_FILE_MODE, type: "blob", sha: null });
continue;
}
const blob = await githubJson(token, "POST", `${repoPath}/git/blobs`, {
content: Buffer.from(file.newContent, "utf-8").toString("base64"),
encoding: "base64",
});
if (typeof blob?.sha !== "string") {
throw new ConfigRepoAgentError("GitHub did not return a blob sha for a config file.");
}
treeEntries.push({ path: file.path, mode: TREE_FILE_MODE, type: "blob", sha: blob.sha });
}
// 5. Build the new tree on top of the base.
const tree = await githubJson(token, "POST", `${repoPath}/git/trees`, { base_tree: baseTreeSha, tree: treeEntries });
if (typeof tree?.sha !== "string") {
throw new ConfigRepoAgentError("GitHub did not return a tree sha for the config commit.");
}
// 6. Create the commit object with the bot identity as author and committer.
const identity = { name: GIT_BOT_NAME, email: GIT_BOT_EMAIL };
const commitObj = await githubJson(token, "POST", `${repoPath}/git/commits`, {
message: commitMessage,
tree: tree.sha,
parents: [change.baseSha],
author: identity,
committer: identity,
});
const commitSha = commitObj?.sha;
if (typeof commitSha !== "string") {
throw new ConfigRepoAgentError("GitHub did not return a commit sha.");
}
// 7. Fast-forward the branch. force:false → GitHub rejects a non-fast-forward
// (someone pushed since our pre-check) with 422, which is a commit conflict.
const update = await githubFetch(token, "PATCH", `${repoPath}/git/refs/heads/${encodedBranch}`, { sha: commitSha, force: false });
if (!update.ok) {
if (update.status === 422) {
throw new ConfigRepoCommitConflictError();
}
const detail = typeof update.json?.message === "string" ? `: ${update.json.message}` : "";
throw new ConfigRepoAgentError(`Failed to update the branch ref (${update.status})${detail}`);
}
return {
mode: "commit-to-branch",
branch: ref.branch,
commitUrl: `https://github.com/${ref.owner}/${ref.repo}/commit/${commitSha}`,
commitSha,
};
}
// ---------------------------------------------------------------------------
// Cancel (hard-stop an in-flight run's sandbox)
// ---------------------------------------------------------------------------
/**
* Hard-stops an in-flight run's sandbox by id (called from the cancel route, a
* different invocation than the one running the agent). Best-effort: a sandbox
* that already finished/stopped just no-ops. If the agent hadn't pushed yet, this
* undoes the change; if a commit already landed, it stays (no revert).
* that already finished/stopped just no-ops. Only `running` runs have a live
* sandbox now once captured (`awaiting_review`) the sandbox is already gone, so
* cancelling then just flips the row to terminal with nothing to stop.
*/
export async function stopConfigAgentSandbox(sandboxId: string): Promise<void> {
await stopSandboxWithContext(sandboxId, "config-repo-agent-cancel-stop");

View File

@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
import {
buildWorkflowYaml,
GITHUB_PROJECT_ID_SECRET_NAME,
GITHUB_SECRET_SERVER_KEY_SECRET_NAME,
normalizeConfigPath,
WORKFLOW_FILE_PATH,
buildWorkflowYaml,
GITHUB_PROJECT_ID_SECRET_NAME,
GITHUB_SECRET_SERVER_KEY_SECRET_NAME,
normalizeConfigPath,
WORKFLOW_FILE_PATH,
} from "./link-existing-onboarding-workflow";
describe("buildWorkflowYaml", () => {

View File

@ -1,4 +1,3 @@
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";
@ -13,9 +12,6 @@ export default function Layout(
{/* Pre-fetch the current URL to prevent request waterfalls */}
<UrlPrefetcher href="" />
{/* Surfaces an in-flight GitHub config-agent run (any tab / after reload). */}
<ConfigAgentRunWatcher />
<SidebarLayout>
{props.children}
{props.modal}

View File

@ -475,3 +475,28 @@ body:has(.show-site-loading-indicator) .site-loading-indicator {
:where([data-pacifica-children-min-width-0] > *) {
min-width: 0;
}
/* Config-agent commit-preview diff (Pierre `@pierre/diffs`).
*
* Pierre renders into a shadow-DOM `diffs-container`. We can't reach inside with
* normal selectors, but CSS custom properties inherit through the shadow
* boundary, so setting Pierre's documented override hooks on the light-DOM
* wrapper recolors the diff chrome. Syntax-token colors still come from the
* shiki github-light/github-dark themes (chosen via `themeType`).
*
* We only set the *base* surface/accent/number colors and let Pierre derive the
* row tints, emphasis, gutters and separators from them (`color-mix` over the
* base). The project tokens already flip between light and dark via `.dark`, so
* both `light-dark()` branches are pointed at the same token. */
.config-agent-diff {
--diffs-light-bg: hsl(var(--background));
--diffs-dark-bg: hsl(var(--background));
--diffs-light: hsl(var(--foreground));
--diffs-dark: hsl(var(--foreground));
--diffs-fg-number-override: hsl(var(--muted-foreground));
--diffs-addition-color-override: hsl(var(--success));
--diffs-deletion-color-override: hsl(var(--destructive));
}

View File

@ -1,165 +0,0 @@
'use client';
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 { ConfigAgentRunProgressContent } from "./progress-content";
import { type AgentStage, type ConfigAgentRun, getAdminInterface, useGithubRunActive } from "./shared";
/**
* 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 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
* modal and flags the run via `useGithubRunActive()`.
*/
type WatcherPhase = "hidden" | "running" | "cancelling" | "error";
type SourceInfo = { owner: string, repo: string, branch: string };
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
type RunSnapshot = { owner: string, repo: string, branch: string, run: ConfigAgentRun | null };
async function readRunSnapshot(adminApp: StackAdminApp<false> | null): Promise<RunSnapshot | null> {
const iface = getAdminInterface(adminApp);
if (iface == null) return null;
try {
const source = await iface.getPushedConfigSource();
if (source.type !== "pushed-from-github") return null;
const run = await iface.getConfigAgentRun();
return { owner: source.owner, repo: source.repo, branch: source.branch, run };
} catch {
return null; // transient — try again next tick
}
}
export function ConfigAgentRunWatcher() {
const adminApp = useAdminAppIfExists();
const githubRunActive = useGithubRunActive();
const [phase, setPhase] = useState<WatcherPhase>("hidden");
const [sourceInfo, setSourceInfo] = useState<SourceInfo | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [activity, setActivity] = useState<string | null>(null);
const [stage, setStage] = useState<AgentStage | null>(null);
const [startedAt, setStartedAt] = useState<number>(0);
const phaseRef = useRef(phase);
phaseRef.current = phase;
const handleCancel = useCallback(async (): Promise<"prevent-close" | undefined> => {
const iface = getAdminInterface(adminApp);
if (iface == null) {
setErrorMessage("This dashboard build can't cancel a config run. Please refresh and try again.");
setPhase("error");
return "prevent-close";
}
setErrorMessage(null);
setPhase("cancelling");
try {
await iface.cancelConfigAgentRun();
} catch (error) {
captureError("config-agent-watcher-cancel", error);
}
return "prevent-close";
}, [adminApp]);
useEffect(() => {
if (!adminApp) return;
const loop = { stopped: false };
let timer: ReturnType<typeof setTimeout> | undefined;
const apply = (snap: RunSnapshot | null): number => {
if (snap == null) {
if (phaseRef.current !== "error") setPhase("hidden");
return UNLINKED_POLL_MS;
}
setSourceInfo({ owner: snap.owner, repo: snap.repo, branch: snap.branch });
const run = snap.run;
// This tab's push dialog owns the modal for runs it started.
if (githubRunActive) {
if (phaseRef.current !== "hidden") setPhase("hidden");
return LINKED_IDLE_POLL_MS;
}
if (run?.status === "running") {
setActivity(run.progress ?? null);
if (run.stage != null) setStage(run.stage);
setStartedAt(run.started_at);
if (phaseRef.current !== "cancelling") setPhase("running");
return ACTIVE_POLL_MS;
}
if (run?.status === "error" && (phaseRef.current === "running" || phaseRef.current === "cancelling")) {
setErrorMessage(run.error ?? "The config agent failed to apply the change.");
setPhase("error");
return LINKED_IDLE_POLL_MS;
}
if (phaseRef.current !== "error") setPhase("hidden");
return LINKED_IDLE_POLL_MS;
};
const tick = async () => {
const snap = await readRunSnapshot(adminApp);
if (loop.stopped) return;
const delay = apply(snap);
timer = setTimeout(() => void tick(), delay);
};
timer = setTimeout(() => void tick(), 0);
return () => {
loop.stopped = true;
if (timer) clearTimeout(timer);
};
}, [adminApp, githubRunActive]);
if (phase === "hidden") return null;
const linked = sourceInfo ? `${sourceInfo.owner}/${sourceInfo.repo}@${sourceInfo.branch}` : "GitHub";
if (phase === "error") {
return (
<ActionDialog
open
onClose={() => setPhase("hidden")}
title="Configuration update failed"
okButton={{ label: "Close", onClick: async () => { setPhase("hidden"); } }}
>
<p className="text-sm text-destructive">
{errorMessage ?? "The config agent failed to apply the change."}
</p>
</ActionDialog>
);
}
return (
<ActionDialog
open
preventClose
titleIcon={GitBranch}
title="Push configuration to GitHub"
description={`Applying your change in a sandbox — ${linked}`}
cancelButton={{
label: phase === "cancelling" ? "Cancelling…" : "Cancel",
onClick: handleCancel,
props: { disabled: phase === "cancelling", variant: "outline" },
}}
>
<ConfigAgentRunProgressContent
isCancelling={phase === "cancelling"}
stage={stage}
startedAt={startedAt}
activity={activity}
errorMessage={errorMessage}
/>
</ActionDialog>
);
}

View File

@ -9,10 +9,10 @@ import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/co
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 React, { Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { AgentDiffViewer, ConfigAgentRunProgressContent } from "./progress-content";
import { ConfigUpdateDialogContext, currentEpochMsFromPerformance, getAdminInterface, type AgentStage, type ConfigAgentRun, type GithubPushedSource } from "./shared";
import { AgentDiffViewer, ConfigAgentPreviewProgress } from "./progress-content";
import { currentEpochMsFromPerformance, getAdminInterface, type AgentStage, type ConfigAgentRun, type GithubPushedSource } from "./shared";
type GithubPushDialogProps = {
open: boolean,
@ -32,12 +32,24 @@ type ScopeCheck =
| { status: "ok", account: OAuthConnection }
| { status: "missing-scopes" };
// "idle": waiting for user to start.
// "running": agent is in flight (non-dismissible; Cancel stops the sandbox).
// "check": verifying the GitHub account is linked with valid scopes; when the
// check passes the run starts automatically. Also the resting state after a
// failed run (shows the error + a manual retry).
//
// The remaining phases all render the SAME commit-form layout (commit message
// input + a "Commit preview" box); only the preview box content and the footer
// buttons differ, so the UI doesn't jump between running and review:
// "running": agent is in flight — preview box shows the live loader; Commit is
// disabled; Cancel stops the sandbox. The commit message can be drafted here.
// "cancelling": user clicked Cancel, waiting for terminal status.
// "awaiting_review": agent done, diff loaded, waiting for user to commit.
// "awaiting_review": agent done, diff loaded into the preview box; Commit enabled.
// "committing": user clicked Commit, pushing to GitHub.
type DialogPhase = "idle" | "running" | "cancelling" | "awaiting_review" | "committing";
type DialogPhase = "check" | "running" | "cancelling" | "awaiting_review" | "committing";
// Phases that share the commit-form layout (everything except the scope check).
function isCommitFormPhase(phase: DialogPhase): boolean {
return phase === "running" || phase === "cancelling" || phase === "awaiting_review" || phase === "committing";
}
function projectSettingsHref(projectId: string | undefined): string {
return `/projects/${projectId}/project-settings`;
@ -49,7 +61,7 @@ function projectSettingsHref(projectId: string | undefined): string {
*/
export function GithubPushDialog({ open, adminApp, source, configUpdate, projectId, onSettle }: GithubPushDialogProps) {
const [scopeStatus, setScopeStatus] = useState<ScopeCheck["status"]>("checking");
const [phase, setPhase] = useState<DialogPhase>("idle");
const [phase, setPhase] = useState<DialogPhase>("check");
const [stage, setStage] = useState<AgentStage | null>(null);
const [startedAt, setStartedAt] = useState<number>(0);
const [activity, setActivity] = useState<string | null>(null);
@ -65,99 +77,55 @@ export function GithubPushDialog({ open, adminApp, source, configUpdate, project
commit: () => Promise<void>,
} | null>(null);
const dialogContext = useContext(ConfigUpdateDialogContext);
const isNonDismissible = phase === "running" || phase === "cancelling" || phase === "committing";
// The commit-form phases explain themselves in the body (so the header stays
// identical across running/review); only the scope-check phase needs a header
// description.
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}`;
}
if (phase !== "check") return undefined;
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.`; }
}
})();
// Footer buttons
const footer = (() => {
if (phase === "running") {
// Shared commit-form footer: Cancel on the left, status + Commit on the right.
// The only per-phase differences are button enabled/loading state and the
// "Waiting for preview…" hint, so running and review keep the same shape.
if (isCommitFormPhase(phase)) {
const canCommit = phase === "awaiting_review";
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-3 w-full">
<DesignButton
variant="outline"
size="sm"
disabled={phase === "cancelling" || phase === "committing"}
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
{phase === "cancelling" ? "Cancelling…" : "Cancel"}
</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
{phase === "running" && (
<span className="text-xs text-muted-foreground whitespace-nowrap">Waiting for preview</span>
)}
<DesignButton
size="sm"
disabled={!canCommit}
loading={phase === "committing"}
onClick={async () => { await handlersRef.current?.commit(); }}
>
{phase !== "committing" && <GitCommit className="h-3.5 w-3.5 mr-1.5" />}
{phase === "committing" ? "Committing…" : "Commit"}
</DesignButton>
</div>
);
}
// idle
// check
return (
<div className="flex items-center gap-2">
<DesignDialogClose asChild>
@ -169,23 +137,25 @@ export function GithubPushDialog({ open, adminApp, source, configUpdate, project
<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"}
>
) : errorMessage != null && configUpdate != null ? (
<DesignButton size="sm" onClick={async () => { await handlersRef.current?.push(); }}>
<ArrowsClockwise className="h-3.5 w-3.5 mr-1.5" />
{errorMessage != null && configUpdate != null ? "Retry update" : "Start update"}
Retry update
</DesignButton>
) : (
// Scope check passing (or still checking) auto-starts the run; show a
// non-interactive loading affordance until the phase flips to "running".
<DesignButton size="sm" disabled loading>
{scopeStatus === "checking" ? "Checking…" : "Starting…"}
</DesignButton>
)}
</div>
);
})();
// Dialog size grows when showing the diff
const dialogSize = phase === "awaiting_review" ? "3xl" : "lg";
// The whole commit form (running → review) uses one width so the dialog never
// resizes when the preview box swaps the loader out for the diff.
const dialogSize = isCommitFormPhase(phase) ? "3xl" : "lg";
return (
<DesignDialog
@ -196,7 +166,7 @@ export function GithubPushDialog({ open, adminApp, source, configUpdate, project
}}
size={dialogSize}
icon={GitBranch}
title="Push configuration to GitHub"
title="Push configuration"
description={description}
hideTopCloseButton={isNonDismissible}
footer={footer}
@ -216,6 +186,7 @@ export function GithubPushDialog({ open, adminApp, source, configUpdate, project
diff={diff}
commitMessage={commitMessage}
errorMessage={errorMessage}
onCommitMessageChange={setCommitMessage}
onScopeStatusChange={setScopeStatus}
onPhaseChange={setPhase}
onStageChange={setStage}
@ -224,7 +195,6 @@ export function GithubPushDialog({ open, adminApp, source, configUpdate, project
onDiffChange={setDiff}
onErrorChange={setErrorMessage}
handlersRef={handlersRef}
dialogContext={dialogContext}
/>
</Suspense>
</DesignDialog>
@ -244,6 +214,7 @@ type GithubPushBodyProps = {
diff: string | null,
commitMessage: string,
errorMessage: string | null,
onCommitMessageChange: (m: string) => void,
onScopeStatusChange: (s: ScopeCheck["status"]) => void,
onPhaseChange: (p: DialogPhase) => void,
onStageChange: (s: AgentStage | null) => void,
@ -257,7 +228,6 @@ type GithubPushBodyProps = {
cancel: () => Promise<void>,
commit: () => Promise<void>,
} | null>,
dialogContext: React.ContextType<typeof ConfigUpdateDialogContext>,
};
function GithubPushBody({
@ -273,6 +243,7 @@ function GithubPushBody({
diff,
commitMessage,
errorMessage,
onCommitMessageChange,
onScopeStatusChange,
onPhaseChange,
onStageChange,
@ -281,7 +252,6 @@ function GithubPushBody({
onDiffChange,
onErrorChange,
handlersRef,
dialogContext,
}: GithubPushBodyProps) {
const user = useDashboardInternalUser();
const githubAccounts = user.useConnectedAccounts().filter((account) => account.provider === "github");
@ -291,6 +261,11 @@ function GithubPushBody({
githubAccounts.length === 0 ? { status: "no-account" } : { status: "checking" },
);
// Id of the run this dialog started, returned by applyConfigViaAgent. Runs are
// independent rows, so we poll/cancel/commit THIS run by id rather than "the"
// run on the branch (another tab may be running its own at the same time).
const runIdRef = useRef<string | null>(null);
useLayoutEffect(() => {
onScopeStatusChange(scopeCheck.status);
}, [scopeCheck.status, onScopeStatusChange]);
@ -350,32 +325,33 @@ function GithubPushBody({
return;
}
await adminInterface.applyConfigViaAgent({
const started = await adminInterface.applyConfigViaAgent({
configUpdate,
githubAccessToken: tokenResult.data.accessToken,
});
const runId = started.id;
runIdRef.current = runId;
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).
// Poll OUR run by id until it leaves "running" (to "awaiting_review", a
// terminal status, or timeout). No stale-filtering needed — the id pins
// exactly this run, independent of any concurrent run on the same branch.
const deadline = performance.now() + 8 * 60_000;
while (performance.now() < deadline) {
await new Promise((r) => setTimeout(r, 3000));
let run: ConfigAgentRun | null;
try {
run = await adminInterface.getConfigAgentRun();
run = await adminInterface.getConfigAgentRun(runId);
} catch {
continue;
}
// Ignore stale runs from before this one started.
if (run == null || run.started_at < runStartedAtWallMs - 5000) continue;
if (run == null) continue;
if (run.status === "running") {
if (run.progress != null) onActivityChange(run.progress);
@ -384,8 +360,6 @@ function GithubPushBody({
}
// Non-running status: transition.
dialogContext?.setGithubRunActive(false);
if (run.status === "awaiting_review") {
onPhaseChange("awaiting_review");
onStageChange("awaiting_review");
@ -393,32 +367,31 @@ function GithubPushBody({
return;
}
if (run.status === "error") {
onPhaseChange("idle");
onPhaseChange("check");
onStageChange(null);
onErrorChange(run.error ?? "The config agent failed to apply your change.");
return;
}
if (run.status === "cancelled") {
onPhaseChange("idle");
onPhaseChange("check");
onStageChange(null);
onSettle(false);
return;
}
if (run.status === "no-change") {
onPhaseChange("idle");
onPhaseChange("check");
onStageChange(null);
onErrorChange("The config agent finished without producing a diff. No commit was created; try the update again.");
return;
}
// success: a poll raced a completed commit. Settle so the dashboard refreshes.
onPhaseChange("idle");
onPhaseChange("check");
onStageChange(null);
onSettle(true);
return;
}
dialogContext?.setGithubRunActive(false);
onPhaseChange("idle");
onPhaseChange("check");
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.`);
@ -431,14 +404,19 @@ function GithubPushBody({
configFilePath: source.configFilePath,
cause: error,
});
dialogContext?.setGithubRunActive(false);
onPhaseChange("idle");
onPhaseChange("check");
onStageChange(null);
onErrorChange("Unknown error pushing to GitHub.");
}
}, [adminApp, configUpdate, dialogContext, onActivityChange, onDiffChange, onErrorChange, onPhaseChange, onSettle, onStageChange, onStartedAtChange, projectId, scopeCheck, source]);
}, [adminApp, configUpdate, onActivityChange, onDiffChange, onErrorChange, onPhaseChange, onSettle, onStageChange, onStartedAtChange, projectId, scopeCheck, source]);
const handleCancel = useCallback(async () => {
const runId = runIdRef.current;
if (runId == null) {
// No run was started by this dialog — nothing to cancel; just close.
onSettle(false);
return;
}
const adminInterface = getAdminInterface(adminApp);
if (adminInterface == null) {
onErrorChange("This dashboard build can't cancel a config run. Please refresh and try again.");
@ -446,14 +424,20 @@ function GithubPushBody({
}
onPhaseChange("cancelling");
try {
await adminInterface.cancelConfigAgentRun();
await adminInterface.cancelConfigAgentRun(runId);
} catch (error) {
captureError("config-update-github-cancel", error);
}
// The poll loop in handlePush will observe the terminal `cancelled` status and settle.
}, [adminApp, onErrorChange, onPhaseChange]);
}, [adminApp, onErrorChange, onPhaseChange, onSettle]);
const handleCommit = useCallback(async () => {
const runId = runIdRef.current;
if (runId == null) {
onPhaseChange("check");
onErrorChange("There is no run to commit. Start the update again.");
return;
}
if (scopeCheck.status !== "ok") {
onErrorChange("GitHub account not connected. Please reconnect and try again.");
return;
@ -472,18 +456,12 @@ function GithubPushBody({
onErrorChange("Could not get a GitHub token. Reconnect your GitHub account and try again.");
return;
}
const result = await adminInterface.commitConfigAgentRun({
const result = await adminInterface.commitConfigAgentRun(runId, {
githubAccessToken: tokenResult.data.accessToken,
commitMessage: commitMessage.trim().length > 0 ? commitMessage : undefined,
});
if (result.status === "sandbox-expired") {
onPhaseChange("idle");
onStageChange(null);
onErrorChange("The sandbox session expired. Please retry the update.");
return;
}
if (result.status === "not-awaiting-review") {
onPhaseChange("idle");
onPhaseChange("check");
onStageChange(null);
onErrorChange("There is no config diff waiting to commit. Start the update again.");
return;
@ -494,24 +472,24 @@ function GithubPushBody({
await new Promise((r) => setTimeout(r, 3000));
let run: ConfigAgentRun | null;
try {
run = await adminInterface.getConfigAgentRun();
run = await adminInterface.getConfigAgentRun(runId);
} catch {
continue;
}
if (run == null || run.status === "awaiting_review") continue;
if (run.status === "success") {
onPhaseChange("idle");
onPhaseChange("check");
onSettle(true);
return;
}
if (run.status === "error") {
onPhaseChange("idle");
onPhaseChange("check");
onStageChange(null);
onErrorChange(run.error ?? "Failed to commit and push the changes. Please try again.");
return;
}
if (run.status === "cancelled") {
onPhaseChange("idle");
onPhaseChange("check");
onSettle(false);
return;
}
@ -520,7 +498,7 @@ function GithubPushBody({
onErrorChange("Timed out waiting for the commit. Check the repository for status.");
} catch (error) {
captureError("config-update-github-commit", error);
onPhaseChange("idle");
onPhaseChange("check");
onStageChange(null);
onErrorChange("Unknown error committing to GitHub.");
}
@ -539,38 +517,89 @@ function GithubPushBody({
handlersRef.current = { push: handlePush, connect: handleConnect, cancel: handleCancel, commit: handleCommit };
}, [handlersRef, handlePush, handleConnect, handleCancel, handleCommit]);
// Auto-run: once the scope check passes, start the run without a manual click.
// Guarded by a ref so a failed run (which lands back in "check" with an error)
// does not retrigger — the user retries explicitly via the "Retry update" button.
const autoStartedRef = useRef(false);
useEffect(() => {
if (
phase === "check" &&
scopeCheck.status === "ok" &&
configUpdate != null &&
errorMessage == null &&
!autoStartedRef.current
) {
autoStartedRef.current = true;
runAsynchronously(handlePush);
}
}, [phase, scopeCheck.status, configUpdate, errorMessage, handlePush]);
// Unlink hint, reused by both layouts.
const unlinkHint = (
<p className="text-xs text-muted-foreground">
If your configuration is no longer sourced from GitHub, you can{" "}
<Link href={projectSettingsHref(projectId)} className="underline">
unlink it in your project settings
</Link>.
</p>
);
// Commit-form layout — one shape shared by running / cancelling /
// awaiting_review / committing. Only the preview box swaps the loader out for
// the diff; everything around it stays put.
if (isCommitFormPhase(phase)) {
const previewReady = phase === "awaiting_review" || phase === "committing";
return (
<div className="space-y-5">
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
This project&apos;s configuration was pushed from a file on GitHub. You can create a GitHub commit to apply your changes.
</p>
{unlinkHint}
</div>
<div className="space-y-1.5">
<label htmlFor="push-commit-msg" className="text-sm font-medium">
Commit message
</label>
<input
id="push-commit-msg"
type="text"
className="w-full h-9 rounded-lg border border-border/50 bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40 transition-colors duration-150 hover:transition-none disabled:opacity-60"
placeholder="chore(hexclave): update config from dashboard"
value={commitMessage}
onChange={(e) => onCommitMessageChange(e.target.value)}
disabled={phase === "cancelling"}
/>
</div>
<div className="space-y-1.5">
<div className="text-sm font-medium">Commit preview</div>
<div className="rounded-xl border border-border/30 bg-background/60 overflow-hidden">
{phase === "cancelling" ? (
<p className="p-6 text-sm text-muted-foreground">Cancelling the update and stopping the agent</p>
) : previewReady && diff != null && diff.trim().length > 0 ? (
<AgentDiffViewer diff={diff} />
) : (
<ConfigAgentPreviewProgress stage={stage} startedAt={startedAt} activity={activity} />
)}
</div>
</div>
{errorMessage != null && (
<DesignAlert variant="error" description={errorMessage} />
)}
</div>
);
}
// Scope-check layout (and the resting state after a failed run).
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 && (
{errorMessage != null && (
<DesignAlert variant="error" description={errorMessage} />
)}
{/* 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>
)}
{unlinkHint}
</div>
);
}

View File

@ -8,7 +8,7 @@ import { HexclaveAssertionError } 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 { RdeApplyDialog } from "./rde-apply-dialog";
import { ConfigUpdateDialogContext } from "./shared";
type ConfigUpdateDialogState = {
@ -19,6 +19,13 @@ type ConfigUpdateDialogState = {
source: PushedConfigSource | null,
};
type RdeDialogState = {
isOpen: boolean,
adminApp: StackAdminApp<false> | null,
configUpdate: EnvironmentConfigOverrideOverride | null,
resolve: ((result: boolean) => void) | null,
};
export function ConfigUpdateDialogProvider({ children }: { children: React.ReactNode }) {
const [dialogState, setDialogState] = useState<ConfigUpdateDialogState>({
isOpen: false,
@ -27,8 +34,12 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React
resolve: null,
source: null,
});
const [githubRunActive, setGithubRunActive] = useState(false);
const [rdeState, setRdeState] = useState<RdeDialogState>({
isOpen: false,
adminApp: null,
configUpdate: null,
resolve: null,
});
const showPushableDialog = useCallback(async (adminApp: StackAdminApp<false>, configUpdate: EnvironmentConfigOverrideOverride): Promise<boolean> => {
const project = await adminApp.getProject();
const source = await project.getPushedConfigSource();
@ -56,6 +67,12 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React
return false;
}, []);
const showRdeApplyDialog = useCallback(async (adminApp: StackAdminApp<false>, configUpdate: EnvironmentConfigOverrideOverride): Promise<boolean> => {
return await new Promise((resolve) => {
setRdeState({ isOpen: true, adminApp, configUpdate, resolve });
});
}, []);
const settleDialog = useCallback((result: boolean) => {
const resolve = dialogState.resolve;
setDialogState({
@ -68,6 +85,17 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React
resolve?.(result);
}, [dialogState.resolve]);
const settleRdeDialog = useCallback((result: boolean) => {
const resolve = rdeState.resolve;
setRdeState({
isOpen: false,
adminApp: null,
configUpdate: null,
resolve: null,
});
resolve?.(result);
}, [rdeState.resolve]);
const projectId = dialogState.adminApp?.projectId;
const renderDialog = () => {
@ -129,9 +157,17 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React
};
return (
<ConfigUpdateDialogContext.Provider value={{ showPushableDialog, githubRunActive, setGithubRunActive }}>
<ConfigUpdateDialogContext.Provider value={{ showPushableDialog, showRdeApplyDialog }}>
{children}
{renderDialog()}
{rdeState.isOpen && (
<RdeApplyDialog
open={rdeState.isOpen}
adminApp={rdeState.adminApp}
configUpdate={rdeState.configUpdate}
onSettle={settleRdeDialog}
/>
)}
</ConfigUpdateDialogContext.Provider>
);
}
@ -151,7 +187,7 @@ export type UpdateConfigOptions = {
};
export function useUpdateConfig() {
const { showPushableDialog } = useConfigUpdateDialog();
const { showPushableDialog, showRdeApplyDialog } = useConfigUpdateDialog();
return useCallback(async (options: UpdateConfigOptions): Promise<boolean> => {
const { adminApp, configUpdate, pushable } = options;
@ -161,10 +197,7 @@ export function useUpdateConfig() {
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;
return await showRdeApplyDialog(adminApp, configUpdate);
}
if (pushable) {
@ -179,7 +212,7 @@ export function useUpdateConfig() {
// eslint-disable-next-line no-restricted-syntax -- this is the hook implementation itself
await project.updateConfig(configUpdate);
return true;
}, [showPushableDialog]);
}, [showPushableDialog, showRdeApplyDialog]);
}
export type ConfigUpdateButtonProps = {

View File

@ -2,18 +2,20 @@
import { captureError } from "@hexclave/shared/dist/utils/errors";
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
import { ArrowsClockwise } from "@phosphor-icons/react";
import React, { useEffect, useState } from "react";
import type { FileDiffProps } from "@pierre/diffs/react";
import type { FileDiffMetadata } from "@pierre/diffs";
import { useThemeWatcher } from "@/lib/theme";
import { currentEpochMsFromPerformance, type AgentStage } from "./shared";
type StepDef = { key: AgentStage, label: string, subLabel?: string };
type StepDef = { key: AgentStage, label: string };
const STAGE_STEPS: StepDef[] = [
{ key: "initializing_sandbox", label: "Initializing sandbox" },
{ key: "initializing_sandbox", label: "Initializing agent" },
{ key: "cloning_repo", label: "Cloning repo" },
{ key: "agent_making_changes", label: "Agent making changes", subLabel: "Editing config file" },
{ key: "agent_making_changes", label: "Generating changes" },
{ key: "awaiting_review", label: "Ready to review" },
];
@ -23,10 +25,68 @@ function stageIndex(stage: AgentStage | null | undefined): number {
}
/**
* 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.
* Live "seconds since the run started" counter. The run's `startedAt` is a
* wall-clock epoch value; we capture the startmount offset ONCE (lazy state)
* and then advance on a monotonic clock. Recomputing the offset every render
* which is what the old code did re-added the elapsed time on top of the
* monotonic delta and made the timer tick at ~2× speed.
*/
export function AgentStageProgress({
function useElapsedSeconds(startedAt: number): number {
const [monotonicElapsedMs, setMonotonicElapsedMs] = useState(0);
useEffect(() => {
const mountedAt = performance.now();
const t = setInterval(() => setMonotonicElapsedMs(performance.now() - mountedAt), 1000);
return () => clearInterval(t);
}, []);
const [initialOffsetMs] = useState(() => Math.max(0, currentEpochMsFromPerformance() - startedAt));
return Math.max(0, Math.floor((initialOffsetMs + monotonicElapsedMs) / 1000));
}
/**
* Generic centered "spinner + title + elapsed" box. Shared by every config-apply
* flow that has a non-interactive "working…" state (the GitHub agent preview and
* the CLI/RDE local apply), so they look and tick identically.
*/
export function ConfigApplyProgressBox({
title,
detail,
activity,
startedAt,
}: {
title: string,
/** Optional sub-line shown before the elapsed counter, e.g. "2/4: Cloning repo". */
detail?: string | null,
/** Optional live activity log; only the last non-empty line is shown. */
activity?: string | null,
/** Unix ms timestamp of when the run started. */
startedAt: number,
}) {
const elapsed = useElapsedSeconds(startedAt);
const lastActivityLine = activity?.split("\n").filter((l) => l.trim()).at(-1);
return (
<div className="flex flex-col items-center justify-center gap-2 py-10 px-6 text-center">
<ArrowsClockwise className="h-7 w-7 text-muted-foreground animate-spin [animation-duration:1.6s]" />
<div className="text-base font-medium text-foreground">{title}</div>
<div className="font-mono text-[11px] text-muted-foreground tabular-nums">
{detail != null && detail.length > 0 ? `${detail}` : ""}{elapsed}s
</div>
{lastActivityLine != null && lastActivityLine.trim().length > 0 && (
<div className="mt-1 max-w-full truncate font-mono text-[11px] text-muted-foreground/80">
<span className="text-primary mr-1.5"></span>
{lastActivityLine}
</div>
)}
</div>
);
}
/**
* Loader rendered inside the GitHub "Commit preview" box while the agent runs.
* Once the run reaches `awaiting_review` the box swaps this out for the diff, so
* the surrounding layout stays identical between the running and review states.
*/
export function ConfigAgentPreviewProgress({
stage,
startedAt,
activity,
@ -36,94 +96,17 @@ export function AgentStageProgress({
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));
const idx = stageIndex(stage);
const stepNumber = idx < 0 ? 1 : idx + 1;
const stepLabel = (idx < 0 ? STAGE_STEPS[0] : STAGE_STEPS[idx]).label;
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>
<ConfigApplyProgressBox
title="Generating preview…"
detail={`${stepNumber}/${STAGE_STEPS.length}: ${stepLabel}`}
activity={activity}
startedAt={startedAt}
/>
);
}
@ -134,6 +117,14 @@ export function ConfigAgentRunProgressContent({
* helpers/imported config files too.
*/
export function AgentDiffViewer({ diff }: { diff: string }) {
// Pierre renders into a shadow-DOM `diffs-container`; it picks light vs dark
// tokens from the host's `color-scheme`, which it derives from `themeType`.
// The dashboard toggles theme via a `.dark` class (not OS preference), so we
// feed it the resolved theme explicitly — otherwise the diff would follow the
// OS and mismatch the rest of the page. The chrome colors (surface, additions,
// deletions, gutter) are remapped to the project tokens via the
// `config-agent-diff` class in globals.css.
const { theme } = useThemeWatcher();
const [renderer, setRenderer] = useState<{
FileDiff: React.ComponentType<FileDiffProps<undefined>>,
files: FileDiffMetadata[],
@ -165,13 +156,14 @@ export function AgentDiffViewer({ diff }: { diff: string }) {
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">
<div className="config-agent-diff max-h-[55vh] space-y-3 overflow-auto p-2">
{renderer.files.map((fileDiff, index) => (
<FileDiff
key={fileDiff.cacheKey ?? `${fileDiff.name}-${index}`}
fileDiff={fileDiff}
options={{
theme: { dark: "github-dark", light: "github-light" },
themeType: theme,
diffStyle: "unified",
hunkSeparators: "line-info-basic",
overflow: "scroll",
@ -183,7 +175,7 @@ export function AgentDiffViewer({ diff }: { diff: string }) {
}
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">
<pre className="max-h-[55vh] overflow-auto bg-muted/20 p-4 font-mono text-[11px] text-foreground leading-relaxed whitespace-pre">
{diff}
</pre>
);

View File

@ -0,0 +1,171 @@
'use client';
import { DesignAlert, DesignButton, DesignDialog, DesignDialogClose } from "@/components/design-components";
import type { 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 { ArrowsClockwise, Terminal } from "@phosphor-icons/react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { ConfigApplyProgressBox } from "./progress-content";
import { currentEpochMsFromPerformance } from "./shared";
type RdeApplyDialogProps = {
open: boolean,
adminApp: StackAdminApp<false> | null,
configUpdate: EnvironmentConfigOverrideOverride | null,
onSettle: (result: boolean) => void,
};
// CLI / remote-development-environment apply. Unlike the GitHub flow there's no
// review step — the change is written straight to the local config file the CLI
// manages and we wait for it to sync. We reuse the same running/cancelling
// presentation so dashboard config applies feel identical across backends.
// "running": apply in flight (non-dismissible; Cancel aborts the request).
// "cancelling": user clicked Cancel, request is aborting.
// "error": apply failed; resting state with a retry.
type RdePhase = "running" | "cancelling" | "error";
/**
* Drives a CLI/RDE local config apply with the shared progress UI. Auto-starts
* on open and settles `true` on success, `false` on cancel/redirect.
*/
export function RdeApplyDialog({ open, adminApp, configUpdate, onSettle }: RdeApplyDialogProps) {
const [phase, setPhase] = useState<RdePhase>("running");
const [startedAt, setStartedAt] = useState<number>(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const autoStartedRef = useRef(false);
const isNonDismissible = phase === "running" || phase === "cancelling";
const startApply = useCallback(() => {
if (adminApp == null || configUpdate == null) {
setErrorMessage("No configuration changes to apply.");
setPhase("error");
return;
}
// Lazily import so this client chunk doesn't pull the RDE client into the
// common config-update bundle.
const controller = new AbortController();
abortRef.current = controller;
setErrorMessage(null);
setStartedAt(currentEpochMsFromPerformance());
setPhase("running");
runAsynchronously(async () => {
try {
const { updateRemoteDevelopmentEnvironmentConfigFile } = await import("./remote-development-environment");
const result = await updateRemoteDevelopmentEnvironmentConfigFile(adminApp, configUpdate, { signal: controller.signal });
if (controller.signal.aborted) {
onSettle(false);
return;
}
// "redirecting": the browser secret flow took over; treat as not-applied.
onSettle(result === "updated");
} catch (error) {
if (controller.signal.aborted) {
onSettle(false);
return;
}
captureError("config-update-rde-apply", error);
setErrorMessage(error instanceof Error ? error.message : "Failed to apply the change to your local development environment.");
setPhase("error");
}
});
}, [adminApp, configUpdate, onSettle]);
// Auto-start once on open; a failed apply lands in "error" and is retried
// explicitly via the Retry button (which calls startApply again).
useEffect(() => {
if (!open || autoStartedRef.current) return;
autoStartedRef.current = true;
startApply();
}, [open, startApply]);
const handleCancel = useCallback(() => {
setPhase("cancelling");
abortRef.current?.abort();
// The in-flight apply observes the aborted signal and settles `false`.
}, []);
const description = (() => {
switch (phase) {
case "running":
case "cancelling": {
return "Applying your change to the local configuration file managed by the Hexclave CLI.";
}
case "error": {
return "The change could not be applied to your local development environment.";
}
}
})();
const footer = (() => {
if (phase === "error") {
return (
<div className="flex items-center gap-2">
<DesignDialogClose asChild>
<DesignButton variant="outline" size="sm" onClick={async () => { onSettle(false); }}>
Close
</DesignButton>
</DesignDialogClose>
<DesignButton size="sm" onClick={async () => { startApply(); }}>
<ArrowsClockwise className="h-3.5 w-3.5 mr-1.5" />
Retry
</DesignButton>
</div>
);
}
// running / cancelling
return (
<DesignButton
variant="outline"
size="sm"
disabled={phase === "cancelling"}
onClick={async () => { handleCancel(); }}
>
{phase === "cancelling" ? "Cancelling…" : "Cancel"}
</DesignButton>
);
})();
return (
<DesignDialog
open={open}
onOpenChange={(o) => {
if (o || isNonDismissible) return;
onSettle(false);
}}
size="lg"
icon={Terminal}
title="Apply configuration"
description={description}
hideTopCloseButton={isNonDismissible}
footer={footer}
contentProps={{ onPointerDownOutside: isNonDismissible ? (e) => e.preventDefault() : undefined, onEscapeKeyDown: isNonDismissible ? (e) => e.preventDefault() : undefined }}
>
<div className="space-y-4">
<div className="rounded-xl border border-border/30 bg-background/60 overflow-hidden">
{phase === "cancelling" ? (
<p className="p-6 text-sm text-muted-foreground">Cancelling the update</p>
) : phase === "error" ? (
<p className="p-6 text-sm text-muted-foreground">No changes were applied. You can retry the update.</p>
) : (
<ConfigApplyProgressBox
title="Applying changes…"
detail="Waiting for the CLI to sync"
startedAt={startedAt}
/>
)}
</div>
{errorMessage != null && (
<DesignAlert variant="error" description={errorMessage} />
)}
</div>
</DesignDialog>
);
}

View File

@ -5,7 +5,11 @@ import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/co
export async function updateRemoteDevelopmentEnvironmentConfigFile(
adminApp: StackAdminApp<false>,
configUpdate: EnvironmentConfigOverrideOverride,
options?: { signal?: AbortSignal },
): Promise<"updated" | "redirecting"> {
// Combine the hard timeout with the caller's cancel signal (the apply dialog's
// Cancel button) so either can abort the in-flight sync.
const signals = [AbortSignal.timeout(130_000), options?.signal].filter((s): s is AbortSignal => s != null);
try {
const response = await fetchWithRemoteDevelopmentEnvironmentBrowserSecret("/api/remote-development-environment/config/apply-update", {
method: "POST",
@ -18,7 +22,7 @@ export async function updateRemoteDevelopmentEnvironmentConfigFile(
config_update: configUpdate,
wait_for_sync: true,
}),
signal: AbortSignal.timeout(130_000),
signal: AbortSignal.any(signals),
});
if (!response.ok) {
throw new Error(`Failed to update local development environment config (${response.status}): ${await response.text()}`);

View File

@ -4,7 +4,7 @@ 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 type { ConfigAgentRunApi } from "@hexclave/shared/dist/schema-fields";
import { createContext, useContext } from "react";
import { createContext } from "react";
/** Live state of a dashboard→GitHub config agent run (mirrors the API schema). */
export type ConfigAgentRun = ConfigAgentRunApi;
@ -36,15 +36,5 @@ export function getAdminInterface(adminApp: StackAdminApp<false> | null | undefi
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,
showRdeApplyDialog: (adminApp: StackAdminApp<false>, configUpdate: EnvironmentConfigOverrideOverride) => Promise<boolean>,
} | null>(null);
/** Read-only accessor for the watcher (mounted below this provider). */
export function useGithubRunActive(): boolean {
return useContext(ConfigUpdateDialogContext)?.githubRunActive ?? false;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -65,7 +65,7 @@ export const config = defineHexclaveConfig({
allowSignUp: true,
password: { allowSignIn: false },
otp: { allowSignIn: true },
passkey: { allowSignIn: false },
passkey: { allowSignIn: true },
oauth: {
accountMergeStrategy: "link_method",
providers: {

View File

@ -14,13 +14,13 @@ import { InternalApiKeysCrud } from "./crud/internal-api-keys";
import { ProjectPermissionDefinitionsCrud } from "./crud/project-permissions";
import { ProjectsCrud } from "./crud/projects";
import type {
AdminGetSessionReplayAllEventsResponse,
AdminGetSessionReplayChunkEventsResponse,
AdminGetSessionReplayResponse,
AdminListSessionReplayChunksOptions,
AdminListSessionReplayChunksResponse,
AdminListSessionReplaysOptions,
AdminListSessionReplaysResponse
AdminGetSessionReplayAllEventsResponse,
AdminGetSessionReplayChunkEventsResponse,
AdminGetSessionReplayResponse,
AdminListSessionReplayChunksOptions,
AdminListSessionReplayChunksResponse,
AdminListSessionReplaysOptions,
AdminListSessionReplaysResponse
} from "./crud/session-replays";
import { SvixTokenCrud } from "./crud/svix-token";
import { TeamPermissionDefinitionsCrud } from "./crud/team-permissions";
@ -835,12 +835,14 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
}
/**
* Reads the most recent config-agent run state (or `null`) for the linked GitHub
* repo. Polled by the dashboard for live progress and the review diff.
* Reads a specific config-agent run's state (or `null`) for the linked GitHub
* repo. Polled by the dashboard using the id returned by `applyConfigViaAgent`
* for live progress and the review diff. Runs are independent, so each is
* addressed by its own id rather than "the" run on the branch.
*/
async getConfigAgentRun(): Promise<ConfigAgentRunApi | null> {
async getConfigAgentRun(runId: string): Promise<ConfigAgentRunApi | null> {
const response = await this.sendAdminRequest(
`/internal/config/github/run`,
`/internal/config/github/run?run_id=${encodeURIComponent(runId)}`,
{ method: "GET" },
null,
);
@ -850,11 +852,11 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
/**
* Applies a dashboard config change to the linked GitHub repo by running the
* config agent in a sandbox (server-side). Returns immediately; poll
* `getConfigAgentRun()` for progress. The GitHub access token is the caller's
* own OAuth token and is used transiently server-side.
* config agent in a sandbox (server-side). Returns immediately with the new run's
* `id`; poll `getConfigAgentRun(id)` for progress. The GitHub access token is the
* caller's own OAuth token and is used transiently server-side.
*/
async applyConfigViaAgent(options: { configUpdate: EnvironmentConfigOverrideOverride, githubAccessToken: string }): Promise<{ status: "started" }> {
async applyConfigViaAgent(options: { configUpdate: EnvironmentConfigOverrideOverride, githubAccessToken: string }): Promise<{ status: "started", id: string }> {
const response = await this.sendAdminRequest(
`/internal/config/github/apply`,
{
@ -871,18 +873,18 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
}
/**
* Cancels the in-flight agent-driven config write: hard-stops the sandbox so
* the agent stops mid-work. Also cancels runs in `awaiting_review`. No revert
* if the agent already pushed, the commit stays. Returns `not-running` if
* no run is in flight.
* Cancels a specific in-flight agent-driven config write: hard-stops the sandbox
* so the agent stops mid-work. Also cancels runs in `awaiting_review`. No revert
* if the agent already pushed, the commit stays. Returns `not-running` if the
* run is gone or already terminal.
*/
async cancelConfigAgentRun(): Promise<{ status: "cancelling" | "not-running" }> {
async cancelConfigAgentRun(runId: string): Promise<{ status: "cancelling" | "not-running" }> {
const response = await this.sendAdminRequest(
`/internal/config/github/cancel`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
body: JSON.stringify({ run_id: runId }),
},
null,
);
@ -890,18 +892,19 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
}
/**
* Commits and pushes the agent's already-applied changes after the user has
* reviewed the diff. Only valid when a run is in `awaiting_review` status.
* Returns `sandbox-expired` if the review state exists but its sandbox id is
* missing, which means the user needs to rerun the agent.
* Commits a specific run's reviewed change to GitHub. Only valid when that run is in
* `awaiting_review` status; the change (diff + base commit) was captured at apply time
* and is rebuilt + pushed via the GitHub API here, so no live sandbox is involved.
* Returns `not-awaiting-review` if the run isn't in a committable state.
*/
async commitConfigAgentRun(options: { githubAccessToken: string, commitMessage?: string }): Promise<{ status: "committing" | "not-awaiting-review" | "sandbox-expired" }> {
async commitConfigAgentRun(runId: string, options: { githubAccessToken: string, commitMessage?: string }): Promise<{ status: "committing" | "not-awaiting-review" }> {
const response = await this.sendAdminRequest(
`/internal/config/github/commit`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
run_id: runId,
github_access_token: options.githubAccessToken,
...(options.commitMessage ? { commit_message: options.commitMessage } : {}),
}),

View File

@ -961,12 +961,14 @@ export const branchConfigSourceSchema = yupUnion(
);
/**
* State of the most recent dashboardGitHub config agent run, so the dashboard can
* poll for progress and surface the resulting commit (or error) across tabs. Stored
* in its own `BranchConfigOverride.configAgentRun` column; runs are NOT serialized,
* so a new run just overwrites this and stale callbacks are ignored by `started_at`.
* State of a single dashboardGitHub config agent run, so the dashboard can poll for
* progress and surface the resulting commit (or error). Each run is one row in the
* `ConfigAgentRun` table, addressed by `id`; runs are NOT serialized, so many can
* target the same branch at once and GitHub catches conflicts at push time.
*/
export const configAgentRunSchema = yupObject({
// The run's id (the `ConfigAgentRun` row id). The dashboard polls/cancels/commits this specific run by id.
id: yupString().defined(),
// "running": agent is working; "awaiting_review": agent done, diff ready, waiting for the user to commit;
// "success" | "no-change" | "error" | "cancelled": terminal.
status: yupString().oneOf(["running", "awaiting_review", "success", "no-change", "error", "cancelled"]).defined(),
@ -974,8 +976,9 @@ export const configAgentRunSchema = yupObject({
finished_at: yupNumber().optional(),
commit_url: urlSchema.optional(),
error: configAgentSafeErrorMessageSchema.optional(),
// Vercel Sandbox id of the in-flight run, recorded while `status` is "running"/"awaiting_review"
// so a cancel request (a different invocation) can hard-stop the sandbox. Absent once terminal.
// Vercel Sandbox id of the in-flight run, recorded only while `status` is "running"
// so a cancel request (a different invocation) can hard-stop the sandbox. Cleared once
// the change set is captured ("awaiting_review") or the run goes terminal.
sandbox_id: yupString().optional(),
// A short, SANITIZED live activity feed (recent agent actions, e.g. "Editing
// hexclave.config.ts", "Running: git push"). Never file contents, tool inputs, or tokens.
@ -989,12 +992,14 @@ export type ConfigAgentRunApi = yup.InferType<typeof configAgentRunSchema>;
import.meta.vitest?.test("configAgentRunSchema only allows safe config-agent error messages", async ({ expect }) => {
expect(await configAgentRunSchema.isValid({
id: "00000000-0000-0000-0000-000000000000",
status: "error",
started_at: 1,
error: "The config agent failed to apply the change.",
})).toMatchInlineSnapshot(`true`);
expect(await configAgentRunSchema.isValid({
id: "00000000-0000-0000-0000-000000000000",
status: "error",
started_at: 1,
error: "ENOENT: tokenized internal failure",

View File

@ -226,6 +226,9 @@ importers:
chokidar-cli:
specifier: ^3.0.0
version: 3.0.0
diff:
specifier: ^8.0.3
version: 8.0.3
dotenv:
specifier: ^16.4.5
version: 16.4.7
@ -804,7 +807,7 @@ importers:
version: 1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@tanstack/react-start':
specifier: ^1.121.3
version: 1.166.6(crossws@0.4.4(srvx@0.8.16))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
version: 1.166.6(crossws@0.4.4(srvx@0.8.16))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
'@tanstack/react-start-client':
specifier: ^1.121.3
version: 1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@ -819,7 +822,7 @@ importers:
version: 1.166.6
'@tanstack/start-plugin-core':
specifier: ^1.121.3
version: 1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(crossws@0.4.4(srvx@0.8.16))(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
version: 1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(crossws@0.4.4(srvx@0.8.16))(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
'@tanstack/start-server-core':
specifier: ^1.121.3
version: 1.166.6(crossws@0.4.4(srvx@0.8.16))
@ -843,7 +846,7 @@ importers:
version: 0.468.0(react@19.2.1)
nitro:
specifier: ^3.0.0
version: 3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2)
version: 3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2)
qrcode:
specifier: ^1.5.4
version: 1.5.4
@ -880,7 +883,7 @@ importers:
version: 19.2.3(@types/react@19.2.7)
'@vitejs/plugin-react':
specifier: ^5.0.0
version: 5.1.4(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))
version: 5.1.4(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))
autoprefixer:
specifier: ^10.4.20
version: 10.4.21(postcss@8.5.6)
@ -898,10 +901,10 @@ importers:
version: 6.0.3
vite:
specifier: ^7.0.0
version: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
version: 7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
vite-tsconfig-paths:
specifier: ^4.3.2
version: 4.3.2(typescript@6.0.3)(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))
version: 4.3.2(typescript@6.0.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))
apps/internal-tool:
dependencies:
@ -1812,7 +1815,7 @@ importers:
version: 1.167.58(crossws@0.4.4(srvx@0.11.15))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
nitro:
specifier: ^3.0.0
version: 3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2)
version: 3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2)
react:
specifier: 19.2.3
version: 19.2.3
@ -2551,7 +2554,7 @@ importers:
devDependencies:
'@quetzallabs/i18n':
specifier: ^0.1.19
version: 0.1.19(next@16.2.9(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
version: 0.1.19(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
'@tanstack/react-router':
specifier: ^1.167.4
version: 1.169.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@ -24501,7 +24504,7 @@ snapshots:
- next
- supports-color
'@quetzallabs/i18n@0.1.19(next@16.2.9(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))':
'@quetzallabs/i18n@0.1.19(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))':
dependencies:
'@babel/parser': 7.29.0
'@babel/traverse': 7.29.0
@ -24509,7 +24512,7 @@ snapshots:
dotenv: 10.0.0
i18next: 21.10.0
i18next-parser: 9.0.2
next-intl: 3.19.1(next@16.2.9(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@18.3.1)
next-intl: 3.19.1(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@18.3.1)
path: 0.12.7
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
@ -28573,19 +28576,19 @@ snapshots:
transitivePeerDependencies:
- crossws
'@tanstack/react-start@1.166.6(crossws@0.4.4(srvx@0.8.16))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))':
'@tanstack/react-start@1.166.6(crossws@0.4.4(srvx@0.8.16))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))':
dependencies:
'@tanstack/react-router': 1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@tanstack/react-start-client': 1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@tanstack/react-start-server': 1.166.6(crossws@0.4.4(srvx@0.8.16))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@tanstack/router-utils': 1.161.4
'@tanstack/start-client-core': 1.166.6
'@tanstack/start-plugin-core': 1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(crossws@0.4.4(srvx@0.8.16))(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
'@tanstack/start-plugin-core': 1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(crossws@0.4.4(srvx@0.8.16))(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
'@tanstack/start-server-core': 1.166.6(crossws@0.4.4(srvx@0.8.16))
pathe: 2.0.3
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
vite: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
vite: 7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
transitivePeerDependencies:
- '@rsbuild/core'
- crossws
@ -28750,7 +28753,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tanstack/router-plugin@1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))':
'@tanstack/router-plugin@1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
@ -28767,7 +28770,7 @@ snapshots:
zod: 3.25.76
optionalDependencies:
'@tanstack/react-router': 1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
vite: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
vite: 7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
webpack: 5.92.0(esbuild@0.24.2)
transitivePeerDependencies:
- supports-color
@ -28886,7 +28889,7 @@ snapshots:
'@tanstack/start-fn-stubs@1.161.6': {}
'@tanstack/start-plugin-core@1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(crossws@0.4.4(srvx@0.8.16))(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))':
'@tanstack/start-plugin-core@1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(crossws@0.4.4(srvx@0.8.16))(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/core': 7.28.5
@ -28894,7 +28897,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.40
'@tanstack/router-core': 1.166.6
'@tanstack/router-generator': 1.166.6
'@tanstack/router-plugin': 1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
'@tanstack/router-plugin': 1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
'@tanstack/router-utils': 1.161.4
'@tanstack/start-client-core': 1.166.6
'@tanstack/start-server-core': 1.166.6(crossws@0.4.4(srvx@0.8.16))
@ -28906,8 +28909,8 @@ snapshots:
srvx: 0.11.9
tinyglobby: 0.2.15
ufo: 1.5.4
vite: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
vitefu: 1.1.2(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))
vite: 7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
vitefu: 1.1.2(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))
xmlbuilder2: 4.0.3
zod: 3.25.76
transitivePeerDependencies:
@ -29904,18 +29907,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
'@rolldown/pluginutils': 1.0.0-rc.3
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
vite: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))':
dependencies:
'@babel/core': 7.29.0
@ -33972,15 +33963,6 @@ snapshots:
optionalDependencies:
crossws: 0.4.4(srvx@0.8.16)
h3@2.0.1-rc.2(crossws@0.4.4(srvx@0.11.15)):
dependencies:
cookie-es: 2.0.0
fetchdts: 0.1.7
rou3: 0.7.12
srvx: 0.8.16
optionalDependencies:
crossws: 0.4.4(srvx@0.11.15)
h3@2.0.1-rc.2(crossws@0.4.4(srvx@0.8.16)):
dependencies:
cookie-es: 2.0.0
@ -36170,11 +36152,11 @@ snapshots:
react: 18.3.1
use-intl: 3.19.1(react@18.3.1)
next-intl@3.19.1(next@16.2.9(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@18.3.1):
next-intl@3.19.1(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@18.3.1):
dependencies:
'@formatjs/intl-localematcher': 0.5.4
negotiator: 0.6.4
next: 16.2.9(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 18.3.1
use-intl: 3.19.1(react@18.3.1)
@ -36501,7 +36483,7 @@ snapshots:
jsonpath-plus: 10.4.0
lodash.topath: 4.5.2
nitro@3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2):
nitro@3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2):
dependencies:
consola: 3.4.2
cookie-es: 2.0.0
@ -36521,59 +36503,6 @@ snapshots:
unenv: 2.0.0-rc.21
unstorage: 2.0.0-alpha.3(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.3.2)(mysql2@3.15.3))(lru-cache@11.2.2)(ofetch@1.5.1)
optionalDependencies:
rolldown: 1.0.0-rc.3
vite: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
xml2js: 0.6.2
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
- '@azure/data-tables'
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- '@capacitor/preferences'
- '@deno/kv'
- '@electric-sql/pglite'
- '@libsql/client'
- '@netlify/blobs'
- '@planetscale/database'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/functions'
- '@vercel/kv'
- aws4fetch
- better-sqlite3
- chokidar
- drizzle-orm
- idb-keyval
- ioredis
- lru-cache
- mongodb
- mysql2
- sqlite3
- uploadthing
nitro@3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2):
dependencies:
consola: 3.4.2
cookie-es: 2.0.0
crossws: 0.4.4(srvx@0.8.16)
db0: 0.3.4(@electric-sql/pglite@0.3.2)(mysql2@3.15.3)
esbuild: 0.25.11
fetchdts: 0.1.7
h3: 2.0.1-rc.2(crossws@0.4.4(srvx@0.11.15))
jiti: 2.6.1
nf3: 0.1.12
ofetch: 1.5.1
ohash: 2.0.11
rendu: 0.0.6
rollup: 4.57.1
srvx: 0.8.16
undici: 7.18.2
unenv: 2.0.0-rc.21
unstorage: 2.0.0-alpha.3(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.3.2)(mysql2@3.15.3))(lru-cache@11.2.2)(ofetch@1.5.1)
optionalDependencies:
rolldown: 1.0.0-rc.3
vite: 7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
xml2js: 0.6.2
transitivePeerDependencies:
@ -40607,17 +40536,6 @@ snapshots:
- supports-color
- typescript
vite-tsconfig-paths@4.3.2(typescript@6.0.3)(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)):
dependencies:
debug: 4.4.3
globrex: 0.1.2
tsconfck: 3.1.5(typescript@6.0.3)
optionalDependencies:
vite: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
transitivePeerDependencies:
- supports-color
- typescript
vite-tsconfig-paths@4.3.2(typescript@6.0.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)):
dependencies:
debug: 4.4.3
@ -40707,23 +40625,6 @@ snapshots:
tsx: 4.19.3
yaml: 2.6.0
vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0):
dependencies:
esbuild: 0.27.1
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.57.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 22.19.0
fsevents: 2.3.3
jiti: 1.21.7
lightningcss: 1.32.0
terser: 5.44.0
tsx: 4.21.0
yaml: 2.8.0
vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0):
dependencies:
esbuild: 0.27.1
@ -40741,10 +40642,6 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.0
vitefu@1.1.2(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)):
optionalDependencies:
vite: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
vitefu@1.1.2(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)):
optionalDependencies:
vite: 7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)