| ... | ... | @@ -166,6 +166,8 @@ interface ScopedServerWrite { |
| 166 | 166 | readonly environmentId: EnvironmentId; |
| 167 | 167 | readonly label: string; |
| 168 | 168 | readonly patch: ServerSettingsPatch; |
| 169 | /** The environment's settings before the write, to tell a dropped key from a saved one. */ |
| 170 | readonly previous: ServerSettings; |
| 169 | 171 | } |
| 170 | 172 | |
| 171 | 173 | function projectOverrideWrites( |
| ... | ... | @@ -194,6 +196,7 @@ function projectOverrideWrites( |
| 194 | 196 | writes.set(member.environmentId, { |
| 195 | 197 | environmentId: member.environmentId, |
| 196 | 198 | label: environment.label, |
| 199 | previous: settings, |
| 197 | 200 | patch: { |
| 198 | 201 | projectSettingsOverrides: { |
| 199 | 202 | ...existing?.patch.projectSettingsOverrides, |
| ... | ... | @@ -266,29 +269,38 @@ export function planScopedSettingsPatch( |
| 266 | 269 | return next as ProjectSettingsOverrides; |
| 267 | 270 | }) |
| 268 | 271 | : scope.kind === "all" || scope.kind === "environment" |
| 269 | | ? connectedEnvironments.map((environment) => ({ |
| 270 | | environmentId: environment.environmentId, |
| 271 | | label: environment.label, |
| 272 | | patch: |
| 273 | | environment.serverConfig?.settings.worktreeCleanup != null && |
| 274 | | serverPatch.storageCleanup && |
| 275 | | Object.keys(serverPatch.storageCleanup).some((key) => key.startsWith("worktree")) |
| 276 | | ? { |
| 277 | | ...serverPatch, |
| 278 | | worktreeCleanup: { |
| 279 | | mode: "custom", |
| 280 | | rules: { |
| 281 | | ...resolveWorktreeCleanup(environment.serverConfig.settings, null), |
| 282 | | ...Object.fromEntries( |
| 283 | | Object.entries(serverPatch.storageCleanup).filter(([key]) => |
| 284 | | key.startsWith("worktree"), |
| 285 | | ), |
| 286 | | ), |
| 287 | | }, |
| 288 | | }, |
| 289 | | } |
| 290 | | : serverPatch, |
| 291 | | })) |
| 272 | ? connectedEnvironments.flatMap((environment) => { |
| 273 | const previous = environment.serverConfig?.settings; |
| 274 | if (!previous) return []; |
| 275 | return [ |
| 276 | { |
| 277 | environmentId: environment.environmentId, |
| 278 | label: environment.label, |
| 279 | previous, |
| 280 | patch: |
| 281 | previous.worktreeCleanup != null && |
| 282 | serverPatch.storageCleanup && |
| 283 | Object.keys(serverPatch.storageCleanup).some((key) => |
| 284 | key.startsWith("worktree"), |
| 285 | ) |
| 286 | ? { |
| 287 | ...serverPatch, |
| 288 | worktreeCleanup: { |
| 289 | mode: "custom", |
| 290 | rules: { |
| 291 | ...resolveWorktreeCleanup(previous, null), |
| 292 | ...Object.fromEntries( |
| 293 | Object.entries(serverPatch.storageCleanup).filter(([key]) => |
| 294 | key.startsWith("worktree"), |
| 295 | ), |
| 296 | ), |
| 297 | }, |
| 298 | }, |
| 299 | } |
| 300 | : serverPatch, |
| 301 | }, |
| 302 | ]; |
| 303 | }) |
| 292 | 304 | : []; |
| 293 | 305 | const hasClientWrite = Object.keys(clientPatch).length > 0; |
| 294 | 306 | const hasWrite = hasClientWrite || serverWrites.length > 0; |
| ... | ... | @@ -371,6 +383,7 @@ export function planProjectOverridesClear( |
| 371 | 383 | writes.set(environmentId, { |
| 372 | 384 | environmentId, |
| 373 | 385 | label: environment.label, |
| 386 | previous: settings, |
| 374 | 387 | patch: { |
| 375 | 388 | projectSettingsOverrides: { |
| 376 | 389 | ...existing?.patch.projectSettingsOverrides, |
| ... | ... | @@ -389,13 +402,56 @@ export function planProjectOverridesClear( |
| 389 | 402 | }; |
| 390 | 403 | } |
| 391 | 404 | |
| 405 | /** |
| 406 | * Whether a server kept `before` where `value` asked for a change. Objects are |
| 407 | * compared field by field, so a nested key an older server dropped is caught |
| 408 | * even when a sibling field saved. A null clears the field; strings may come |
| 409 | * back trimmed. |
| 410 | */ |
| 411 | function writeWasDropped(value: unknown, before: unknown, saved: unknown): boolean { |
| 412 | if (value === null) return before != null && Equal.equals(saved, before); |
| 413 | if (isPlainObject(value) && !Array.isArray(before) && !Array.isArray(saved)) { |
| 414 | const field = (source: unknown, key: string) => |
| 415 | isPlainObject(source) ? source[key] : undefined; |
| 416 | return Object.entries(value).some(([key, next]) => |
| 417 | writeWasDropped(next, field(before, key), field(saved, key)), |
| 418 | ); |
| 419 | } |
| 420 | const unchanged = |
| 421 | typeof value === "string" && typeof before === "string" |
| 422 | ? value.trim() === before |
| 423 | : Equal.equals(value, before); |
| 424 | return !unchanged && Equal.equals(saved, before); |
| 425 | } |
| 426 | |
| 427 | /** |
| 428 | * An older server drops keys it does not know, top-level or nested, and still |
| 429 | * succeeds. Left unreported, the row stays mixed forever. |
| 430 | */ |
| 431 | function droppedSettingKeys(write: ScopedServerWrite, saved: ServerSettings | undefined) { |
| 432 | if (saved === undefined) return []; |
| 433 | return Object.entries(write.patch).flatMap(([key, value]) => |
| 434 | writeWasDropped( |
| 435 | value, |
| 436 | write.previous[key as keyof ServerSettings], |
| 437 | saved[key as keyof ServerSettings], |
| 438 | ) |
| 439 | ? [key] |
| 440 | : [], |
| 441 | ); |
| 442 | } |
| 443 | |
| 392 | 444 | /** Wait for every target so a failed environment does not hide successful or later writes. */ |
| 393 | 445 | export async function persistScopedSettingsPatch( |
| 394 | 446 | plan: ReturnType<typeof planScopedSettingsPatch>, |
| 395 | 447 | persistServer: (input: { |
| 396 | 448 | environmentId: EnvironmentId; |
| 397 | 449 | input: { patch: ServerSettingsPatch }; |
| 398 | | }) => Promise<{ readonly _tag: "Success" | "Failure"; readonly cause?: Cause.Cause<unknown> }>, |
| 450 | }) => Promise<{ |
| 451 | readonly _tag: "Success" | "Failure"; |
| 452 | readonly cause?: Cause.Cause<unknown>; |
| 453 | readonly value?: ServerSettings; |
| 454 | }>, |
| 399 | 455 | persistClient: (patch: ClientSettingsPatch) => void, |
| 400 | 456 | ) { |
| 401 | 457 | if (plan.hasClientWrite) persistClient(plan.clientPatch); |
| ... | ... | @@ -404,9 +460,25 @@ export async function persistScopedSettingsPatch( |
| 404 | 460 | persistServer({ environmentId, input: { patch } }), |
| 405 | 461 | ), |
| 406 | 462 | ); |
| 463 | const saved = (index: number) => { |
| 464 | const result = results[index]; |
| 465 | return ( |
| 466 | result?.status === "fulfilled" && |
| 467 | result.value._tag === "Success" && |
| 468 | droppedSettingKeys(plan.serverWrites[index]!, result.value.value).length === 0 |
| 469 | ); |
| 470 | }; |
| 407 | 471 | const failedEnvironments = plan.serverWrites.flatMap((environment, index) => { |
| 472 | if (saved(index)) return []; |
| 408 | 473 | const result = results[index]; |
| 409 | | if (result?.status === "fulfilled" && result.value._tag === "Success") return []; |
| 474 | if (result?.status === "fulfilled" && result.value._tag === "Success") { |
| 475 | return [ |
| 476 | { |
| 477 | ...environment, |
| 478 | message: "This environment does not support this setting. Update T3 Code on it.", |
| 479 | }, |
| 480 | ]; |
| 481 | } |
| 410 | 482 | const error: unknown = |
| 411 | 483 | result?.status === "rejected" |
| 412 | 484 | ? result.reason |
| ... | ... | @@ -423,10 +495,7 @@ export async function persistScopedSettingsPatch( |
| 423 | 495 | }, |
| 424 | 496 | ]; |
| 425 | 497 | }); |
| 426 | | const savedEnvironments = plan.serverWrites.filter((_, index) => { |
| 427 | | const result = results[index]; |
| 428 | | return result?.status === "fulfilled" && result.value._tag === "Success"; |
| 429 | | }); |
| 498 | const savedEnvironments = plan.serverWrites.filter((_, index) => saved(index)); |
| 430 | 499 | return { |
| 431 | 500 | failedEnvironments, |
| 432 | 501 | savedEnvironments, |