mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
feat: enhance configuration update process and improve test coverage
- Updated `next.config.mjs` to include dynamic path resolution for the `@anthropic-ai/claude-agent-sdk`, improving output file tracing. - Refactored tests in `config-file.test.ts` to clarify the shared agent updater's functionality and ensure it can handle updates to both config and imported files in a single run. - Modified `manager.ts` to utilize `updateConfigObject` for applying configuration updates, enhancing the reliability of remote environment updates. - Improved the `updateConfigObject` function in `local-config-updater` to include baseline configuration in the update prompt, ensuring expected outcomes are clearly defined. These changes enhance the configuration management and testing capabilities within the Hexclave ecosystem.
This commit is contained in:
parent
74143b74d5
commit
449f5cc526
@ -1,4 +1,12 @@
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import { createRequire } from "module";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const localConfigUpdaterRequire = createRequire(path.join(__dirname, "../../packages/local-config-updater/package.json"));
|
||||
const claudeAgentSdkDir = path.dirname(localConfigUpdaterRequire.resolve("@anthropic-ai/claude-agent-sdk"));
|
||||
const claudeAgentSdkTraceDir = path.relative(__dirname, claudeAgentSdkDir);
|
||||
|
||||
const withConfiguredSentryConfig = (nextConfig) =>
|
||||
withSentryConfig(
|
||||
@ -48,13 +56,14 @@ const nextConfig = {
|
||||
// optionally set output to "standalone" for Docker builds
|
||||
// https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
output: process.env.NEXT_CONFIG_OUTPUT,
|
||||
outputFileTracingRoot: path.join(__dirname, "../.."),
|
||||
outputFileTracingIncludes: {
|
||||
"/*": [
|
||||
"./node_modules/@anthropic-ai/claude-agent-sdk/cli.js",
|
||||
"./node_modules/@anthropic-ai/claude-agent-sdk/manifest.json",
|
||||
"./node_modules/@anthropic-ai/claude-agent-sdk/manifest.zst.json",
|
||||
"./node_modules/@anthropic-ai/claude-agent-sdk/resvg.wasm",
|
||||
"./node_modules/@anthropic-ai/claude-agent-sdk/vendor/**/*",
|
||||
"/api/remote-development-environment/config/apply-update": [
|
||||
path.join(claudeAgentSdkTraceDir, "cli.js"),
|
||||
path.join(claudeAgentSdkTraceDir, "manifest.json"),
|
||||
path.join(claudeAgentSdkTraceDir, "manifest.zst.json"),
|
||||
path.join(claudeAgentSdkTraceDir, "resvg.wasm"),
|
||||
path.join(claudeAgentSdkTraceDir, "vendor/**/*"),
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Lets each AI-path test inject what the (otherwise network-backed) agent does
|
||||
// to the files on disk, so we can exercise the orchestration and validation
|
||||
// around `updateConfigObject` without calling a real model.
|
||||
type MockAgentOptions = { prompt: string, cwd: string, onFileWillChange?: (filePath: string) => void | Promise<void> };
|
||||
let mockAgentImpl: ((options: MockAgentOptions) => void | Promise<void>) | null = null;
|
||||
|
||||
@ -210,7 +207,7 @@ describe("remote development environment config file", () => {
|
||||
await expect(readConfigFile(configPath)).rejects.toThrow(`Invalid config in ${configPath}.`);
|
||||
});
|
||||
|
||||
it("applies updates to a plain static config through the agent", async () => {
|
||||
it("applies updates to a plain static config through the shared agent updater", async () => {
|
||||
const configPath = writeTempConfig(`
|
||||
export const config = {
|
||||
auth: {
|
||||
@ -260,8 +257,6 @@ describe("remote development environment config file", () => {
|
||||
|
||||
const { updateConfigObject } = await import("./config-file");
|
||||
|
||||
// Simulate the agent: write the new value into the referenced file and leave
|
||||
// the config file untouched.
|
||||
mockAgentImpl = () => {
|
||||
writeFileSync(templatePath, "export default <div>New email</div>;\n", "utf-8");
|
||||
};
|
||||
@ -270,138 +265,28 @@ describe("remote development environment config file", () => {
|
||||
"emails.templates.welcome": "export default <div>New email</div>;\n",
|
||||
});
|
||||
|
||||
// The external file is updated and the config file keeps its import + shape.
|
||||
expect(readFileSync(templatePath, "utf-8")).toBe("export default <div>New email</div>;\n");
|
||||
expect(readFileSync(configPath, "utf-8")).toBe(configSource);
|
||||
});
|
||||
|
||||
it("validates the result semantically when the config is evaluable", async () => {
|
||||
const configPath = writeTempConfig(`
|
||||
import { defineStackConfig } from "@hexclave/shared/config";
|
||||
export const config = defineStackConfig({
|
||||
auth: { allowSignUp: true },
|
||||
});
|
||||
`);
|
||||
const { readConfigFile, updateConfigObject } = await import("./config-file");
|
||||
|
||||
mockAgentImpl = () => {
|
||||
writeFileSync(configPath, `
|
||||
import { defineStackConfig } from "@hexclave/shared/config";
|
||||
export const config = defineStackConfig({
|
||||
auth: { allowSignUp: false },
|
||||
});
|
||||
`, "utf-8");
|
||||
};
|
||||
|
||||
await updateConfigObject(configPath, { "auth.allowSignUp": false });
|
||||
|
||||
// The defineStackConfig wrapper is preserved and the value is updated.
|
||||
expect(readFileSync(configPath, "utf-8")).toContain("defineStackConfig");
|
||||
await expect(readConfigFile(configPath)).resolves.toMatchInlineSnapshot(`
|
||||
{
|
||||
"config": {
|
||||
"auth": {
|
||||
"allowSignUp": false,
|
||||
},
|
||||
},
|
||||
"showOnboarding": false,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("throws when the agent produces a config that does not match the requested update", async () => {
|
||||
const configPath = writeTempConfig(`
|
||||
import { defineStackConfig } from "@hexclave/shared/config";
|
||||
export const config = defineStackConfig({
|
||||
auth: { allowSignUp: true },
|
||||
});
|
||||
`);
|
||||
const { updateConfigObject } = await import("./config-file");
|
||||
|
||||
// The agent writes the wrong value (allowSignUp stays true).
|
||||
mockAgentImpl = () => {
|
||||
writeFileSync(configPath, `
|
||||
import { defineStackConfig } from "@hexclave/shared/config";
|
||||
export const config = defineStackConfig({
|
||||
auth: { allowSignUp: true },
|
||||
});
|
||||
`, "utf-8");
|
||||
};
|
||||
|
||||
await expect(updateConfigObject(configPath, { "auth.allowSignUp": false }))
|
||||
.rejects.toThrow(/validation failed/);
|
||||
});
|
||||
|
||||
it("rolls back the config and its referenced files when the agent's result fails validation", async () => {
|
||||
it("can update config and imported text files in one shared agent run", async () => {
|
||||
const templatePath = writeTempFile("welcome-email.tsx", "export default <div>Old email</div>;\n");
|
||||
const configSource = `import welcomeEmail from "./welcome-email.tsx" with { type: "text" };\n\nexport const config = {\n emails: { templates: { welcome: welcomeEmail } },\n};\n`;
|
||||
const configSource = `import welcomeEmail from "./welcome-email.tsx" with { type: "text" };\n\nexport const config = {\n auth: { allowSignUp: true },\n emails: { templates: { welcome: welcomeEmail } },\n};\n`;
|
||||
const configPath = writeTempConfig(configSource);
|
||||
|
||||
const { updateConfigObject } = await import("./config-file");
|
||||
|
||||
// The agent edits both files but then fails, so the partially-applied edits
|
||||
// must be rolled back and the failure surfaced.
|
||||
mockAgentImpl = () => {
|
||||
writeFileSync(templatePath, "export default <div>Corrupted</div>;\n", "utf-8");
|
||||
writeFileSync(configPath, `export const config = { auth: { allowSignUp: true } };\n`, "utf-8");
|
||||
throw new Error("agent blew up");
|
||||
writeFileSync(templatePath, "export default <div>New email</div>;\n", "utf-8");
|
||||
writeFileSync(configPath, `import welcomeEmail from "./welcome-email.tsx" with { type: "text" };\n\nexport const config = {\n auth: { allowSignUp: false },\n emails: { templates: { welcome: welcomeEmail } },\n};\n`, "utf-8");
|
||||
};
|
||||
|
||||
await expect(updateConfigObject(configPath, {
|
||||
await updateConfigObject(configPath, {
|
||||
"auth.allowSignUp": false,
|
||||
"emails.templates.welcome": "export default <div>New email</div>;\n",
|
||||
})).rejects.toThrow("agent blew up");
|
||||
});
|
||||
|
||||
// Both the config file and the externally-referenced file are back to their
|
||||
// original contents \u2014 no half-applied update is left behind.
|
||||
expect(readFileSync(configPath, "utf-8")).toBe(configSource);
|
||||
expect(readFileSync(templatePath, "utf-8")).toBe("export default <div>Old email</div>;\n");
|
||||
});
|
||||
|
||||
it("rolls back a brand-new file the agent creates outside the statically-imported set", async () => {
|
||||
// A wrapped config takes the agent path (it isn't a plain static literal),
|
||||
// so the agent mock actually runs.
|
||||
const configSource = `import { defineStackConfig } from "@hexclave/shared/config";\nexport const config = defineStackConfig({ auth: { allowSignUp: true } });\n`;
|
||||
const configPath = writeTempConfig(configSource);
|
||||
const newFilePath = join(getTempDir(), "generated-extra.ts");
|
||||
|
||||
const { updateConfigObject } = await import("./config-file");
|
||||
|
||||
// The agent creates a file that the config doesn't statically import, so the
|
||||
// only way it can be rolled back is via the `onFileWillChange` hook firing
|
||||
// before the write. After creating it, the agent fails so the run must roll
|
||||
// back — deleting the newly-created file.
|
||||
mockAgentImpl = async (options) => {
|
||||
await options.onFileWillChange?.(newFilePath);
|
||||
writeFileSync(newFilePath, "export const extra = true;\n", "utf-8");
|
||||
throw new Error("agent blew up");
|
||||
};
|
||||
|
||||
await expect(updateConfigObject(configPath, { "auth.allowSignUp": false }))
|
||||
.rejects.toThrow("agent blew up");
|
||||
|
||||
expect(existsSync(newFilePath)).toBe(false);
|
||||
expect(readFileSync(configPath, "utf-8")).toBe(configSource);
|
||||
});
|
||||
|
||||
it("fails a non-evaluable update when the agent leaves every file unchanged", async () => {
|
||||
const templatePath = writeTempFile("welcome-email.tsx", "export default <div>Old email</div>;\n");
|
||||
const configSource = `import welcomeEmail from "./welcome-email.tsx" with { type: "text" };\n\nexport const config = {\n emails: { templates: { welcome: welcomeEmail } },\n};\n`;
|
||||
const configPath = writeTempConfig(configSource);
|
||||
|
||||
const { updateConfigObject } = await import("./config-file");
|
||||
|
||||
// The agent reports success but doesn't actually touch any file. Since this
|
||||
// config isn't evaluable, we can't do a semantic check, but a no-op for a
|
||||
// non-empty update must still be reported as a failure rather than silently
|
||||
// succeeding.
|
||||
mockAgentImpl = () => {};
|
||||
|
||||
await expect(updateConfigObject(configPath, {
|
||||
"emails.templates.welcome": "export default <div>New email</div>;\n",
|
||||
})).rejects.toThrow(/did not modify/);
|
||||
|
||||
// The files are untouched (a no-op restored to its identical original).
|
||||
expect(readFileSync(configPath, "utf-8")).toBe(configSource);
|
||||
expect(readFileSync(templatePath, "utf-8")).toBe("export default <div>Old email</div>;\n");
|
||||
expect(readFileSync(templatePath, "utf-8")).toBe("export default <div>New email</div>;\n");
|
||||
expect(readFileSync(configPath, "utf-8")).toBe(`import welcomeEmail from "./welcome-email.tsx" with { type: "text" };\n\nexport const config = {\n auth: { allowSignUp: false },\n emails: { templates: { welcome: welcomeEmail } },\n};\n`);
|
||||
});
|
||||
});
|
||||
|
||||
@ -14,9 +14,10 @@ import { basename, dirname } from "path";
|
||||
import {
|
||||
ensureConfigFileExists,
|
||||
readConfigFile,
|
||||
replaceConfigObject,
|
||||
resolveConfigFilePath,
|
||||
sha256String,
|
||||
replaceConfigObject,
|
||||
updateConfigObject,
|
||||
} from "./config-file";
|
||||
import { assertRemoteDevelopmentEnvironmentEnabled } from "./env";
|
||||
import {
|
||||
@ -653,7 +654,7 @@ export async function applyRemoteDevelopmentEnvironmentConfigUpdate(options: {
|
||||
} else {
|
||||
state.synchronouslyUpdatingConfigFiles.add(configFilePath);
|
||||
try {
|
||||
await replaceConfigObject(configFilePath, override(currentConfig, options.configUpdate));
|
||||
await updateConfigObject(configFilePath, options.configUpdate);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
state.synchronouslyUpdatingConfigFiles.delete(configFilePath);
|
||||
|
||||
@ -114,7 +114,7 @@ export async function updateConfigObject(configFilePath: string, configUpdate: C
|
||||
const snapshots = snapshotConfigFiles(configFilePath, content);
|
||||
try {
|
||||
await runConfigUpdateAgent({
|
||||
prompt: buildConfigUpdatePrompt(path.basename(configFilePath), configUpdate),
|
||||
prompt: buildConfigUpdatePrompt(path.basename(configFilePath), configUpdate, baselineConfig),
|
||||
cwd: path.dirname(configFilePath),
|
||||
onFileWillChange: (filePath) => captureSnapshotIfAbsent(snapshots, filePath),
|
||||
});
|
||||
@ -283,11 +283,17 @@ function flattenConfigUpdate(update: Config): ConfigChange[] {
|
||||
return changes;
|
||||
}
|
||||
|
||||
function buildConfigUpdatePrompt(configFileName: string, configUpdate: Config): string {
|
||||
function buildConfigUpdatePrompt(configFileName: string, configUpdate: Config, baselineConfig: Config | null): string {
|
||||
const changes = flattenConfigUpdate(configUpdate);
|
||||
const changeLines = changes.map(({ path: configPath, value }) => {
|
||||
return `- ${JSON.stringify(configPath)}: set to ${JSON.stringify(value)}`;
|
||||
}).join("\n");
|
||||
const expectedConfig = baselineConfig == null ? null : canonicalizeConfig(override(baselineConfig, configUpdate));
|
||||
const expectedConfigSection = expectedConfig == null ? "" : `
|
||||
After the edit, evaluating the exported \`config\` must produce this exact JSON value:
|
||||
|
||||
${JSON.stringify(expectedConfig, null, 2)}
|
||||
`;
|
||||
|
||||
return `You are editing a Hexclave / Stack Auth configuration file in place. Apply a set of configuration changes WITHOUT changing how the file is written.
|
||||
|
||||
@ -301,6 +307,7 @@ The file exports a \`config\` object (it may be wrapped in a helper such as \`de
|
||||
Apply EXACTLY these changes. Paths use dot notation, so \`a.b.c\` refers to \`config.a.b.c\`:
|
||||
|
||||
${changeLines}
|
||||
${expectedConfigSection}
|
||||
|
||||
Rules:
|
||||
- Change ONLY the config paths listed above. Leave every other part of the file byte-for-byte unchanged: imports, comments, formatting, helper wrappers, and any config fields not listed.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user