mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
Improve local agent update logging
This commit is contained in:
parent
dff454f47f
commit
912eea4f7f
@ -36,6 +36,7 @@ const CONFIG_SYNC_FORMAT_VERSION = 2;
|
||||
const LOG_PREFIX = "[Stack RDE]";
|
||||
const RDE_DASHBOARD_LOG_PATH_ENV_VAR = "HEXCLAVE_RDE_DASHBOARD_LOG_PATH";
|
||||
const CONFIG_SYNC_DUPLICATE_EVENT_SUPPRESSION_MS = 2_000;
|
||||
const CONFIG_UPDATE_LOG_PATH_LIMIT = 40;
|
||||
|
||||
export class RemoteDevelopmentEnvironmentApiUnavailableError extends Error {
|
||||
constructor(apiBaseUrl: string, cause: unknown) {
|
||||
@ -205,6 +206,45 @@ function warnRemoteDevelopmentEnvironment(message: string, details?: Record<stri
|
||||
console.warn(line);
|
||||
}
|
||||
|
||||
function formatErrorForRemoteDevelopmentEnvironmentLog(error: unknown): Record<string, unknown> {
|
||||
if (error instanceof Error) {
|
||||
const cause = Reflect.get(error, "cause");
|
||||
return {
|
||||
errorName: error.name,
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack,
|
||||
...(cause == null ? {} : { errorCause: errorToNiceString(cause) }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
errorMessage: errorToNiceString(error),
|
||||
};
|
||||
}
|
||||
|
||||
function collectConfigUpdatePaths(config: Config, prefix: string, paths: string[]): void {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value === undefined) continue;
|
||||
const path = prefix.length === 0 ? key : `${prefix}.${key}`;
|
||||
if (value != null && typeof value === "object" && !Array.isArray(value)) {
|
||||
collectConfigUpdatePaths(value, path, paths);
|
||||
} else {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeConfigUpdateForLog(configUpdate: Config): Record<string, unknown> {
|
||||
const configUpdatePaths: string[] = [];
|
||||
collectConfigUpdatePaths(configUpdate, "", configUpdatePaths);
|
||||
configUpdatePaths.sort();
|
||||
return {
|
||||
configUpdateTopLevelKeys: Object.keys(configUpdate).sort(),
|
||||
configUpdatePathCount: configUpdatePaths.length,
|
||||
configUpdatePaths: configUpdatePaths.slice(0, CONFIG_UPDATE_LOG_PATH_LIMIT),
|
||||
configUpdatePathsTruncated: configUpdatePaths.length > CONFIG_UPDATE_LOG_PATH_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
function configSyncEventsMatchForCliDeduplication(a: ConfigSyncEvent, b: { status: "success" } | { status: "error", errorMessage: string }): boolean {
|
||||
if (a.status !== b.status) {
|
||||
return false;
|
||||
@ -315,6 +355,7 @@ function getStackAppRequestInternals(appValue: unknown): HexclaveAppRequestInter
|
||||
|
||||
function beginRemoteDevelopmentEnvironmentOperation(name: string, details?: Record<string, unknown>): () => void {
|
||||
const state = getGlobals();
|
||||
const startedAtMs = performance.now();
|
||||
state.activeOperations += 1;
|
||||
logRemoteDevelopmentEnvironment(`Started ${name}`, {
|
||||
...details,
|
||||
@ -329,6 +370,7 @@ function beginRemoteDevelopmentEnvironmentOperation(name: string, details?: Reco
|
||||
logRemoteDevelopmentEnvironment(`Finished ${name}`, {
|
||||
...details,
|
||||
activeOperations: state.activeOperations,
|
||||
elapsedMs: Math.round(performance.now() - startedAtMs),
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -1069,63 +1111,125 @@ export async function applyRemoteDevelopmentEnvironmentConfigUpdate(options: {
|
||||
waitForSync?: boolean,
|
||||
}): Promise<void> {
|
||||
assertRemoteDevelopmentEnvironmentEnabled();
|
||||
const endOperation = beginRemoteDevelopmentEnvironmentOperation("config update", {
|
||||
const configUpdateOperationId = randomUUID();
|
||||
const waitForSync = options.waitForSync ?? true;
|
||||
const configUpdateLogDetails = {
|
||||
configUpdateOperationId,
|
||||
sessionId: options.sessionId,
|
||||
projectId: options.projectId,
|
||||
waitForSync,
|
||||
...summarizeConfigUpdateForLog(options.configUpdate),
|
||||
};
|
||||
const endOperation = beginRemoteDevelopmentEnvironmentOperation("config update", {
|
||||
...configUpdateLogDetails,
|
||||
});
|
||||
let resolvedConfigFilePath: string | undefined;
|
||||
try {
|
||||
const state = getGlobals();
|
||||
const session = (() => {
|
||||
const sessionEntry = (() => {
|
||||
if (options.sessionId != null) {
|
||||
return state.sessions.get(options.sessionId);
|
||||
const session = state.sessions.get(options.sessionId);
|
||||
return session == null ? undefined : { sessionId: options.sessionId, session };
|
||||
}
|
||||
if (options.projectId == null) {
|
||||
throw new Error("Remote development environment config update requires a session ID or project ID.");
|
||||
}
|
||||
for (const activeSession of state.sessions.values()) {
|
||||
for (const [sessionId, activeSession] of state.sessions.entries()) {
|
||||
const stateProject = readRemoteDevelopmentEnvironmentState().projectsByConfigPath[activeSession.configFilePath];
|
||||
if (stateProject?.projectId === options.projectId) {
|
||||
return activeSession;
|
||||
return { sessionId, session: activeSession };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
if (session == null) {
|
||||
if (sessionEntry == null) {
|
||||
warnRemoteDevelopmentEnvironment("Could not resolve active session for config update", {
|
||||
...configUpdateLogDetails,
|
||||
activeSessions: state.sessions.size,
|
||||
});
|
||||
throw new Error("Remote development environment session is not active.");
|
||||
}
|
||||
const session = sessionEntry.session;
|
||||
const configFilePath = session.configFilePath;
|
||||
resolvedConfigFilePath = configFilePath;
|
||||
logRemoteDevelopmentEnvironment("Resolved active session for config update", {
|
||||
...configUpdateLogDetails,
|
||||
resolvedSessionId: sessionEntry.sessionId,
|
||||
configFilePath,
|
||||
activeSessions: state.sessions.size,
|
||||
});
|
||||
logRemoteDevelopmentEnvironment("Applying config update from local dashboard", {
|
||||
sessionId: options.sessionId,
|
||||
projectId: options.projectId,
|
||||
...configUpdateLogDetails,
|
||||
resolvedSessionId: sessionEntry.sessionId,
|
||||
configFilePath,
|
||||
});
|
||||
if (options.waitForSync === false) {
|
||||
const localWriteStartedAtMs = performance.now();
|
||||
if (!waitForSync) {
|
||||
logRemoteDevelopmentEnvironment("Writing config update without waiting for remote sync", {
|
||||
...configUpdateLogDetails,
|
||||
resolvedSessionId: sessionEntry.sessionId,
|
||||
configFilePath,
|
||||
});
|
||||
const currentConfig = (await readConfigFile(configFilePath)).config;
|
||||
await replaceConfigObject(configFilePath, override(currentConfig, options.configUpdate));
|
||||
scheduleSync(configFilePath);
|
||||
logRemoteDevelopmentEnvironment("Wrote config update and scheduled remote sync", {
|
||||
...configUpdateLogDetails,
|
||||
resolvedSessionId: sessionEntry.sessionId,
|
||||
configFilePath,
|
||||
localWriteElapsedMs: Math.round(performance.now() - localWriteStartedAtMs),
|
||||
});
|
||||
} else {
|
||||
state.synchronouslyUpdatingConfigFiles.add(configFilePath);
|
||||
try {
|
||||
logRemoteDevelopmentEnvironment("Writing config update before immediate remote sync", {
|
||||
...configUpdateLogDetails,
|
||||
resolvedSessionId: sessionEntry.sessionId,
|
||||
configFilePath,
|
||||
});
|
||||
await updateConfigObject(configFilePath, options.configUpdate);
|
||||
logRemoteDevelopmentEnvironment("Wrote config update before immediate remote sync", {
|
||||
...configUpdateLogDetails,
|
||||
resolvedSessionId: sessionEntry.sessionId,
|
||||
configFilePath,
|
||||
localWriteElapsedMs: Math.round(performance.now() - localWriteStartedAtMs),
|
||||
});
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
state.synchronouslyUpdatingConfigFiles.delete(configFilePath);
|
||||
}, SYNC_DEBOUNCE_MS).unref();
|
||||
}
|
||||
try {
|
||||
const syncStartedAtMs = performance.now();
|
||||
const result = await syncConfigToRemoteNow(configFilePath);
|
||||
if (result.pushedConfig) {
|
||||
recordConfigSyncEvent(configFilePath, { status: "success" });
|
||||
}
|
||||
logRemoteDevelopmentEnvironment("Immediate remote sync after config update completed", {
|
||||
...configUpdateLogDetails,
|
||||
resolvedSessionId: sessionEntry.sessionId,
|
||||
configFilePath,
|
||||
syncElapsedMs: Math.round(performance.now() - syncStartedAtMs),
|
||||
pushedConfig: result.pushedConfig,
|
||||
onboardingStatus: result.onboardingStatus,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = errorToNiceString(error);
|
||||
warnRemoteDevelopmentEnvironment("Immediate remote sync after config update failed", {
|
||||
...configUpdateLogDetails,
|
||||
resolvedSessionId: sessionEntry.sessionId,
|
||||
configFilePath,
|
||||
...formatErrorForRemoteDevelopmentEnvironmentLog(error),
|
||||
});
|
||||
state.syncErrors.set(configFilePath, error instanceof Error ? error : new Error(errorMessage));
|
||||
const cliErrorMessage = formatConfigSyncErrorForCli(configFilePath, error);
|
||||
await updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath, cliErrorMessage)
|
||||
.catch((pushedConfigErrorUpdateError: unknown) => {
|
||||
warnRemoteDevelopmentEnvironment("Failed to update pushed config error after dashboard config sync failure", {
|
||||
...configUpdateLogDetails,
|
||||
resolvedSessionId: sessionEntry.sessionId,
|
||||
configFilePath,
|
||||
error: errorToNiceString(pushedConfigErrorUpdateError),
|
||||
...formatErrorForRemoteDevelopmentEnvironmentLog(pushedConfigErrorUpdateError),
|
||||
});
|
||||
});
|
||||
recordConfigSyncEvent(configFilePath, {
|
||||
@ -1136,11 +1240,17 @@ export async function applyRemoteDevelopmentEnvironmentConfigUpdate(options: {
|
||||
}
|
||||
}
|
||||
logRemoteDevelopmentEnvironment("Applied config update from local dashboard", {
|
||||
sessionId: options.sessionId,
|
||||
projectId: options.projectId,
|
||||
...configUpdateLogDetails,
|
||||
resolvedSessionId: sessionEntry.sessionId,
|
||||
configFilePath,
|
||||
waitForSync: options.waitForSync ?? true,
|
||||
});
|
||||
} catch (error) {
|
||||
warnRemoteDevelopmentEnvironment("Failed to apply config update from local dashboard", {
|
||||
...configUpdateLogDetails,
|
||||
...(resolvedConfigFilePath == null ? {} : { configFilePath: resolvedConfigFilePath }),
|
||||
...formatErrorForRemoteDevelopmentEnvironmentLog(error),
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
endOperation();
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ const jiti = createJiti(import.meta.url, { moduleCache: false });
|
||||
|
||||
const LOG_PREFIX = "[Stack config updater]";
|
||||
const DEFAULT_AGENT_TIMEOUT_MS = 120_000;
|
||||
const CONFIG_UPDATE_LOG_PATH_LIMIT = 40;
|
||||
|
||||
type ConfigModule = {
|
||||
config?: unknown,
|
||||
@ -21,6 +22,28 @@ type ConfigModule = {
|
||||
type ConfigFileSnapshot = { path: string, content: string | null };
|
||||
type ConfigChange = { path: string, value: ConfigValue };
|
||||
|
||||
function formatConfigUpdaterErrorForLog(error: unknown): Record<string, unknown> {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
errorName: error.name,
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack,
|
||||
};
|
||||
}
|
||||
return {
|
||||
errorMessage: String(error),
|
||||
};
|
||||
}
|
||||
|
||||
function configUpdatePathDetailsForLog(changes: ConfigChange[]): Record<string, unknown> {
|
||||
const paths = changes.map(({ path: configPath }) => configPath).sort();
|
||||
return {
|
||||
configUpdatePathCount: paths.length,
|
||||
configUpdatePaths: paths.slice(0, CONFIG_UPDATE_LOG_PATH_LIMIT),
|
||||
configUpdatePathsTruncated: paths.length > CONFIG_UPDATE_LOG_PATH_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
function isConfigModule(value: unknown): value is ConfigModule {
|
||||
return value !== null && typeof value === "object";
|
||||
}
|
||||
@ -124,9 +147,21 @@ function renderConfigObjectToFile(configFilePath: string, config: Config): void
|
||||
}
|
||||
|
||||
export async function updateConfigObject(configFilePath: string, configUpdate: Config): Promise<void> {
|
||||
const startedAtMs = performance.now();
|
||||
ensureConfigFileExists(configFilePath);
|
||||
|
||||
if (flattenConfigUpdate(configUpdate).length === 0) return;
|
||||
const changes = flattenConfigUpdate(configUpdate);
|
||||
if (changes.length === 0) {
|
||||
console.log(`${LOG_PREFIX} Skipping config update because it contains no changes`, {
|
||||
configFilePath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const updateLogDetails = {
|
||||
configFilePath,
|
||||
...configUpdatePathDetailsForLog(changes),
|
||||
};
|
||||
console.log(`${LOG_PREFIX} Starting config file update`, updateLogDetails);
|
||||
|
||||
const content = readFileSync(configFilePath, "utf-8");
|
||||
|
||||
@ -134,16 +169,25 @@ export async function updateConfigObject(configFilePath: string, configUpdate: C
|
||||
// apply the update deterministically without invoking the AI agent.
|
||||
const staticConfig = tryParseStaticConfigFileContent(content, configFilePath);
|
||||
if (staticConfig != null && isValidConfig(staticConfig)) {
|
||||
console.log(`${LOG_PREFIX} Applying config update with static config rewrite`, updateLogDetails);
|
||||
const merged = override(staticConfig, configUpdate);
|
||||
if (!isValidConfig(merged)) {
|
||||
throw new Error(`${LOG_PREFIX} Merged config is invalid after applying update to ${configFilePath}`);
|
||||
}
|
||||
renderConfigObjectToFile(configFilePath, merged);
|
||||
console.log(`${LOG_PREFIX} Finished config update with static config rewrite`, {
|
||||
...updateLogDetails,
|
||||
elapsedMs: Math.round(performance.now() - startedAtMs),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent path: config has custom structure (imports, helpers, external files)
|
||||
// that must be preserved — delegate to the AI agent.
|
||||
console.log(`${LOG_PREFIX} Applying config update with agent-assisted rewrite`, {
|
||||
...updateLogDetails,
|
||||
configDirectory: path.dirname(configFilePath),
|
||||
});
|
||||
const baselineConfig = await tryReadConfigForValidation(configFilePath);
|
||||
const { snapshots, seen } = snapshotConfigFiles(configFilePath, content);
|
||||
try {
|
||||
@ -154,16 +198,31 @@ export async function updateConfigObject(configFilePath: string, configUpdate: C
|
||||
});
|
||||
await validateAgentUpdate(configFilePath, baselineConfig, configUpdate, snapshots);
|
||||
} catch (error) {
|
||||
console.warn(`${LOG_PREFIX} Config update failed; restoring files from snapshots`, {
|
||||
...updateLogDetails,
|
||||
snapshotCount: snapshots.length,
|
||||
elapsedMs: Math.round(performance.now() - startedAtMs),
|
||||
...formatConfigUpdaterErrorForLog(error),
|
||||
});
|
||||
try {
|
||||
restoreConfigFiles(snapshots);
|
||||
console.warn(`${LOG_PREFIX} Restored files after failed config update`, {
|
||||
...updateLogDetails,
|
||||
snapshotCount: snapshots.length,
|
||||
});
|
||||
} catch (restoreError) {
|
||||
console.error(`${LOG_PREFIX} Failed to fully roll back config files after a failed update of ${configFilePath}; some files may be left in a partially-restored state`, {
|
||||
configFilePath,
|
||||
restoreError: restoreError instanceof Error ? restoreError.message : String(restoreError),
|
||||
...formatConfigUpdaterErrorForLog(restoreError),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
console.log(`${LOG_PREFIX} Finished config update with agent-assisted rewrite`, {
|
||||
...updateLogDetails,
|
||||
elapsedMs: Math.round(performance.now() - startedAtMs),
|
||||
snapshotCount: snapshots.length,
|
||||
});
|
||||
}
|
||||
|
||||
export async function replaceConfigObject(configFilePath: string, config: Config): Promise<void> {
|
||||
@ -177,6 +236,11 @@ async function runConfigUpdateAgent(options: {
|
||||
}): Promise<void> {
|
||||
const timeoutMs = parseAgentTimeoutMs();
|
||||
const deniedOutOfBoundsWrites = new Set<string>();
|
||||
const startedAtMs = performance.now();
|
||||
console.log(`${LOG_PREFIX} Starting config update agent`, {
|
||||
cwd: options.cwd,
|
||||
timeoutMs,
|
||||
});
|
||||
try {
|
||||
await runHeadlessClaudeAgent({
|
||||
prompt: options.prompt,
|
||||
@ -204,14 +268,43 @@ async function runConfigUpdateAgent(options: {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ClaudeAgentTimeoutError) {
|
||||
console.warn(`${LOG_PREFIX} Config update agent timed out`, {
|
||||
cwd: options.cwd,
|
||||
timeoutMs,
|
||||
elapsedMs: Math.round(performance.now() - startedAtMs),
|
||||
...formatConfigUpdaterErrorForLog(error),
|
||||
});
|
||||
throw new Error(`Config update agent timed out after ${timeoutMs}ms. It was unable to apply the config changes to the file.`);
|
||||
}
|
||||
if (error instanceof ClaudeAgentFailureError) {
|
||||
console.warn(`${LOG_PREFIX} Config update agent failed`, {
|
||||
cwd: options.cwd,
|
||||
timeoutMs,
|
||||
elapsedMs: Math.round(performance.now() - startedAtMs),
|
||||
...formatConfigUpdaterErrorForLog(error),
|
||||
});
|
||||
throw new Error(`${error.message} It was unable to apply the config changes to the file.`);
|
||||
}
|
||||
console.warn(`${LOG_PREFIX} Config update agent failed unexpectedly`, {
|
||||
cwd: options.cwd,
|
||||
timeoutMs,
|
||||
elapsedMs: Math.round(performance.now() - startedAtMs),
|
||||
...formatConfigUpdaterErrorForLog(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
console.log(`${LOG_PREFIX} Finished config update agent`, {
|
||||
cwd: options.cwd,
|
||||
timeoutMs,
|
||||
elapsedMs: Math.round(performance.now() - startedAtMs),
|
||||
deniedOutOfBoundsWriteCount: deniedOutOfBoundsWrites.size,
|
||||
});
|
||||
if (deniedOutOfBoundsWrites.size > 0) {
|
||||
console.warn(`${LOG_PREFIX} Config update agent attempted out-of-bounds writes`, {
|
||||
cwd: options.cwd,
|
||||
deniedOutOfBoundsWriteCount: deniedOutOfBoundsWrites.size,
|
||||
deniedOutOfBoundsWrites: [...deniedOutOfBoundsWrites],
|
||||
});
|
||||
throw new Error(`Config update agent tried to modify ${deniedOutOfBoundsWrites.size} file(s) outside the config directory, which is not allowed: ${[...deniedOutOfBoundsWrites].join(", ")}. The config was not updated.`);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user